fix: type errors

This commit is contained in:
Alejandro Gómez
2025-12-18 16:00:56 +01:00
parent 1c981a4e12
commit a6650ff6e1
19 changed files with 448 additions and 293 deletions

View File

@@ -18,58 +18,55 @@ export default function KindsViewer() {
<CenteredContent>
{/* Header */}
<div>
<h1 className="text-2xl font-bold mb-2">
Supported Event Kinds ({sortedKinds.length})
</h1>
<p className="text-sm text-muted-foreground">
Event kinds with rich rendering support in Grimoire. Default kinds
display raw content only.
</p>
</div>
<h1 className="text-2xl font-bold mb-2">
Supported Event Kinds ({sortedKinds.length})
</h1>
<p className="text-sm text-muted-foreground">
Event kinds with rich rendering support in Grimoire. Default kinds
display raw content only.
</p>
</div>
{/* Kind List */}
<div className="border border-border divide-y divide-border">
{sortedKinds.map((kind) => {
const kindInfo = getKindInfo(kind);
const Icon = kindInfo?.icon;
{/* Kind List */}
<div className="border border-border divide-y divide-border">
{sortedKinds.map((kind) => {
const kindInfo = getKindInfo(kind);
const Icon = kindInfo?.icon;
return (
<div
key={kind}
className="p-4 hover:bg-muted/30 transition-colors"
>
<div className="flex items-start gap-4">
{/* Icon */}
<div className="w-10 h-10 bg-accent/20 rounded flex items-center justify-center flex-shrink-0">
{Icon ? (
<Icon className="w-5 h-5 text-accent" />
) : (
<span className="text-xs font-mono text-muted-foreground">
{kind}
</span>
)}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-baseline gap-2 mb-1">
<code className="text-sm font-mono font-semibold">
{kind}
</code>
<span className="text-sm font-semibold">
{kindInfo?.name || `Kind ${kind}`}
</span>
</div>
<p className="text-sm text-muted-foreground mb-2">
{kindInfo?.description || "No description available"}
</p>
{kindInfo?.nip && <NIPBadge nipNumber={kindInfo.nip} />}
return (
<div key={kind} className="p-4 hover:bg-muted/30 transition-colors">
<div className="flex items-start gap-4">
{/* Icon */}
<div className="w-10 h-10 bg-accent/20 rounded flex items-center justify-center flex-shrink-0">
{Icon ? (
<Icon className="w-5 h-5 text-accent" />
) : (
<span className="text-xs font-mono text-muted-foreground">
{kind}
</span>
)}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-baseline gap-2 mb-1">
<code className="text-sm font-mono font-semibold">
{kind}
</code>
<span className="text-sm font-semibold">
{kindInfo?.name || `Kind ${kind}`}
</span>
</div>
<p className="text-sm text-muted-foreground mb-2">
{kindInfo?.description || "No description available"}
</p>
{kindInfo?.nip && <NIPBadge nipNumber={kindInfo.nip} />}
</div>
</div>
);
})}
</div>
</div>
);
})}
</div>
</CenteredContent>
);
}

View File

@@ -10,7 +10,6 @@ import {
import { Button } from "./ui/button";
import { Slider } from "./ui/slider";
import { useGrimoire } from "@/core/state";
import { cn } from "@/lib/utils";
import { getAllPresets } from "@/lib/layout-presets";
import {
DropdownMenu,
@@ -25,7 +24,7 @@ import { useState } from "react";
export function LayoutControls() {
const { state, applyPresetLayout, updateLayoutConfig } = useGrimoire();
const { workspaces, activeWorkspaceId, layoutConfig } = state;
const { workspaces, activeWorkspaceId } = state;
// Local state for immediate slider feedback (debounced persistence)
const [localSplitPercentage, setLocalSplitPercentage] = useState<
@@ -33,9 +32,15 @@ export function LayoutControls() {
>(null);
const activeWorkspace = workspaces[activeWorkspaceId];
const layoutConfig = activeWorkspace?.layoutConfig;
const windowCount = activeWorkspace?.windowIds.length || 0;
const presets = getAllPresets();
// Early return if no active workspace or layout config
if (!activeWorkspace || !layoutConfig) {
return null;
}
const handleApplyPreset = (presetId: string) => {
const preset = presets.find((p) => p.id === presetId);
if (!preset) return;

View File

@@ -16,7 +16,9 @@ export function NipRenderer({ nipId, className = "" }: NipRendererProps) {
if (loading) {
return (
<CenteredContent className={cn("text-muted-foreground text-sm", className)}>
<CenteredContent
className={cn("text-muted-foreground text-sm", className)}
>
Loading NIP-{nipId}...
</CenteredContent>
);

View File

@@ -68,60 +68,60 @@ export default function NipsViewer() {
<CenteredContent>
{/* Header */}
<div>
<h1 className="text-2xl font-bold mb-2">
{search
? `Showing ${filteredNips.length} of ${sortedNips.length} NIPs`
: `Nostr Implementation Possibilities (${sortedNips.length})`}
</h1>
<p className="text-sm text-muted-foreground mb-4">
Protocol specifications and extensions for the Nostr network. Click
any NIP to view its full specification document.
</p>
<h1 className="text-2xl font-bold mb-2">
{search
? `Showing ${filteredNips.length} of ${sortedNips.length} NIPs`
: `Nostr Implementation Possibilities (${sortedNips.length})`}
</h1>
<p className="text-sm text-muted-foreground mb-4">
Protocol specifications and extensions for the Nostr network. Click
any NIP to view its full specification document.
</p>
{/* Search Input */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
<Input
ref={searchInputRef}
type="text"
placeholder="Search NIPs by number or title..."
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={handleKeyDown}
className="pl-9 pr-9"
/>
{search && (
<Button
variant="ghost"
size="sm"
onClick={handleClear}
className="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7 p-0 hover:bg-muted"
aria-label="Clear search"
>
<X className="h-4 w-4" />
</Button>
)}
</div>
{/* Search Input */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
<Input
ref={searchInputRef}
type="text"
placeholder="Search NIPs by number or title..."
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={handleKeyDown}
className="pl-9 pr-9"
/>
{search && (
<Button
variant="ghost"
size="sm"
onClick={handleClear}
className="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7 p-0 hover:bg-muted"
aria-label="Clear search"
>
<X className="h-4 w-4" />
</Button>
)}
</div>
</div>
{/* NIP List */}
{filteredNips.length > 0 ? (
<div className="flex flex-col gap-0">
{filteredNips.map((nipId) => (
<NIPBadge
className="border-none"
key={nipId}
showName
nipNumber={nipId}
/>
))}
</div>
) : (
<div className="text-center py-12 text-muted-foreground">
<p className="text-lg mb-2">No NIPs match "{search}"</p>
<p className="text-sm">Try searching for a different term</p>
</div>
)}
{/* NIP List */}
{filteredNips.length > 0 ? (
<div className="flex flex-col gap-0">
{filteredNips.map((nipId) => (
<NIPBadge
className="border-none"
key={nipId}
showName
nipNumber={nipId}
/>
))}
</div>
) : (
<div className="text-center py-12 text-muted-foreground">
<p className="text-lg mb-2">No NIPs match "{search}"</p>
<p className="text-sm">Try searching for a different term</p>
</div>
)}
</CenteredContent>
);
}

View File

@@ -1,8 +1,4 @@
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "./ui/popover";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
import { useGrimoire } from "@/core/state";
import type { LayoutConfig } from "@/types/app";
import { cn } from "@/lib/utils";
@@ -24,7 +20,13 @@ export function WorkspaceSettings({
children,
}: WorkspaceSettingsProps) {
const { state, updateLayoutConfig } = useGrimoire();
const config = state.layoutConfig;
const activeWorkspace = state.workspaces[state.activeWorkspaceId];
const config = activeWorkspace?.layoutConfig;
// Early return if no config available
if (!config) {
return null;
}
const setInsertionMode = (mode: LayoutConfig["insertionMode"]) => {
updateLayoutConfig({ insertionMode: mode });
@@ -64,7 +66,7 @@ export function WorkspaceSettings({
"w-full flex items-center gap-2 px-2 py-1.5 text-xs rounded transition-colors",
isActive
? "bg-accent text-accent-foreground"
: "hover:bg-muted"
: "hover:bg-muted",
)}
>
<Icon className="h-3.5 w-3.5" />

View File

@@ -80,10 +80,7 @@ export function LiveActivityDetailRenderer({
</div>
{/* Host */}
<UserName
pubkey={hostPubkey}
className="text-sm text-accent"
/>
<UserName pubkey={hostPubkey} className="text-sm text-accent" />
{/* Description */}
{activity.summary && (

View File

@@ -7,7 +7,17 @@ interface CenteredContentProps {
* Maximum width of the centered content
* @default '3xl' (48rem / 768px)
*/
maxWidth?: "sm" | "md" | "lg" | "xl" | "2xl" | "3xl" | "4xl" | "5xl" | "6xl" | "full";
maxWidth?:
| "sm"
| "md"
| "lg"
| "xl"
| "2xl"
| "3xl"
| "4xl"
| "5xl"
| "6xl"
| "full";
/**
* Vertical spacing between child elements
* @default '6' (1.5rem)
@@ -98,7 +108,7 @@ export function CenteredContent({
"mx-auto",
maxWidthClasses[maxWidth],
spacingClasses[spacing],
className
className,
)}
>
{children}

View File

@@ -19,7 +19,7 @@ const PopoverContent = React.forwardRef<
sideOffset={sideOffset}
className={cn(
"z-50 rounded-md border bg-popover text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
className,
)}
{...props}
/>

View File

@@ -1,7 +1,7 @@
import * as React from "react"
import * as SliderPrimitive from "@radix-ui/react-slider"
import * as React from "react";
import * as SliderPrimitive from "@radix-ui/react-slider";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
@@ -11,7 +11,7 @@ const Slider = React.forwardRef<
ref={ref}
className={cn(
"relative flex w-full touch-none select-none items-center",
className
className,
)}
{...props}
>
@@ -20,7 +20,7 @@ const Slider = React.forwardRef<
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-4 w-4 rounded-full border border-primary/50 bg-background shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root>
))
Slider.displayName = SliderPrimitive.Root.displayName
));
Slider.displayName = SliderPrimitive.Root.displayName;
export { Slider }
export { Slider };

View File

@@ -130,17 +130,20 @@ describe("findLowestAvailableWorkspaceNumber", () => {
describe("addWindow", () => {
// Helper to create minimal test state
const createTestState = (layoutConfig: LayoutConfig, existingLayout: MosaicNode<string> | null = null): GrimoireState => ({
const createTestState = (
layoutConfig: LayoutConfig,
existingLayout: MosaicNode<string> | null = null,
): GrimoireState => ({
__version: 9,
windows: {},
activeWorkspaceId: "test-workspace",
layoutConfig, // Global layout config (not per-workspace)
workspaces: {
"test-workspace": {
id: "test-workspace",
number: 1,
windowIds: [],
layout: existingLayout,
layoutConfig, // Per-workspace layout config
},
},
});
@@ -207,12 +210,19 @@ describe("addWindow", () => {
describe("second window with row config", () => {
it("should create horizontal split", () => {
const state = createTestState({
insertionMode: "row",
splitPercentage: 50,
insertionPosition: "second",
}, "window-1");
state.windows["window-1"] = { id: "window-1", appId: "profile", props: {} };
const state = createTestState(
{
insertionMode: "row",
splitPercentage: 50,
insertionPosition: "second",
},
"window-1",
);
state.windows["window-1"] = {
id: "window-1",
appId: "profile",
props: {},
};
state.workspaces["test-workspace"].windowIds = ["window-1"];
const result = addWindow(state, {
@@ -229,12 +239,19 @@ describe("addWindow", () => {
});
it("should respect custom split percentage", () => {
const state = createTestState({
insertionMode: "row",
splitPercentage: 70,
insertionPosition: "second",
}, "window-1");
state.windows["window-1"] = { id: "window-1", appId: "profile", props: {} };
const state = createTestState(
{
insertionMode: "row",
splitPercentage: 70,
insertionPosition: "second",
},
"window-1",
);
state.windows["window-1"] = {
id: "window-1",
appId: "profile",
props: {},
};
state.workspaces["test-workspace"].windowIds = ["window-1"];
const result = addWindow(state, {
@@ -250,12 +267,19 @@ describe("addWindow", () => {
});
it("should place new window on right when position is second", () => {
const state = createTestState({
insertionMode: "row",
splitPercentage: 50,
insertionPosition: "second",
}, "window-1");
state.windows["window-1"] = { id: "window-1", appId: "profile", props: {} };
const state = createTestState(
{
insertionMode: "row",
splitPercentage: 50,
insertionPosition: "second",
},
"window-1",
);
state.windows["window-1"] = {
id: "window-1",
appId: "profile",
props: {},
};
state.workspaces["test-workspace"].windowIds = ["window-1"];
const result = addWindow(state, {
@@ -270,12 +294,19 @@ describe("addWindow", () => {
});
it("should place new window on left when position is first", () => {
const state = createTestState({
insertionMode: "row",
splitPercentage: 50,
insertionPosition: "first",
}, "window-1");
state.windows["window-1"] = { id: "window-1", appId: "profile", props: {} };
const state = createTestState(
{
insertionMode: "row",
splitPercentage: 50,
insertionPosition: "first",
},
"window-1",
);
state.windows["window-1"] = {
id: "window-1",
appId: "profile",
props: {},
};
state.workspaces["test-workspace"].windowIds = ["window-1"];
const result = addWindow(state, {
@@ -292,12 +323,19 @@ describe("addWindow", () => {
describe("second window with column config", () => {
it("should create vertical split", () => {
const state = createTestState({
insertionMode: "column",
splitPercentage: 50,
insertionPosition: "second",
}, "window-1");
state.windows["window-1"] = { id: "window-1", appId: "profile", props: {} };
const state = createTestState(
{
insertionMode: "column",
splitPercentage: 50,
insertionPosition: "second",
},
"window-1",
);
state.windows["window-1"] = {
id: "window-1",
appId: "profile",
props: {},
};
state.workspaces["test-workspace"].windowIds = ["window-1"];
const result = addWindow(state, {
@@ -314,12 +352,19 @@ describe("addWindow", () => {
});
it("should place new window on bottom when position is second", () => {
const state = createTestState({
insertionMode: "column",
splitPercentage: 50,
insertionPosition: "second",
}, "window-1");
state.windows["window-1"] = { id: "window-1", appId: "profile", props: {} };
const state = createTestState(
{
insertionMode: "column",
splitPercentage: 50,
insertionPosition: "second",
},
"window-1",
);
state.windows["window-1"] = {
id: "window-1",
appId: "profile",
props: {},
};
state.workspaces["test-workspace"].windowIds = ["window-1"];
const result = addWindow(state, {
@@ -334,12 +379,19 @@ describe("addWindow", () => {
});
it("should place new window on top when position is first", () => {
const state = createTestState({
insertionMode: "column",
splitPercentage: 50,
insertionPosition: "first",
}, "window-1");
state.windows["window-1"] = { id: "window-1", appId: "profile", props: {} };
const state = createTestState(
{
insertionMode: "column",
splitPercentage: 50,
insertionPosition: "first",
},
"window-1",
);
state.windows["window-1"] = {
id: "window-1",
appId: "profile",
props: {},
};
state.workspaces["test-workspace"].windowIds = ["window-1"];
const result = addWindow(state, {
@@ -356,12 +408,19 @@ describe("addWindow", () => {
describe("second window with smart config", () => {
it("should create horizontal split for first split", () => {
const state = createTestState({
insertionMode: "smart",
splitPercentage: 50,
insertionPosition: "second",
}, "window-1");
state.windows["window-1"] = { id: "window-1", appId: "profile", props: {} };
const state = createTestState(
{
insertionMode: "smart",
splitPercentage: 50,
insertionPosition: "second",
},
"window-1",
);
state.windows["window-1"] = {
id: "window-1",
appId: "profile",
props: {},
};
state.workspaces["test-workspace"].windowIds = ["window-1"];
const result = addWindow(state, {
@@ -385,12 +444,19 @@ describe("addWindow", () => {
second: "window-2",
splitPercentage: 50,
};
const state = createTestState({
insertionMode: "smart",
splitPercentage: 50,
insertionPosition: "second",
}, existingLayout);
state.windows["window-1"] = { id: "window-1", appId: "profile", props: {} };
const state = createTestState(
{
insertionMode: "smart",
splitPercentage: 50,
insertionPosition: "second",
},
existingLayout,
);
state.windows["window-1"] = {
id: "window-1",
appId: "profile",
props: {},
};
state.windows["window-2"] = { id: "window-2", appId: "nip", props: {} };
state.workspaces["test-workspace"].windowIds = ["window-1", "window-2"];
@@ -400,10 +466,14 @@ describe("addWindow", () => {
});
const workspace = result.workspaces["test-workspace"];
// Should add column split to balance (1 row, 0 column → add column)
// NEW BEHAVIOR: Splits shallowest leaf (window-1 or window-2 at depth 1)
// Root remains row, but creates column split at the leaf
expect(workspace.layout).toMatchObject({
direction: "column",
direction: "row",
});
// The first child should now be a column split containing the original window and new window
const layout = workspace.layout as any;
expect(layout.first).toHaveProperty("direction", "column");
});
it("should balance by adding horizontal split when vertical exists", () => {
@@ -414,12 +484,19 @@ describe("addWindow", () => {
second: "window-2",
splitPercentage: 50,
};
const state = createTestState({
insertionMode: "smart",
splitPercentage: 50,
insertionPosition: "second",
}, existingLayout);
state.windows["window-1"] = { id: "window-1", appId: "profile", props: {} };
const state = createTestState(
{
insertionMode: "smart",
splitPercentage: 50,
insertionPosition: "second",
},
existingLayout,
);
state.windows["window-1"] = {
id: "window-1",
appId: "profile",
props: {},
};
state.windows["window-2"] = { id: "window-2", appId: "nip", props: {} };
state.workspaces["test-workspace"].windowIds = ["window-1", "window-2"];
@@ -429,10 +506,14 @@ describe("addWindow", () => {
});
const workspace = result.workspaces["test-workspace"];
// Should add row split to balance (0 row, 1 column → add row)
// NEW BEHAVIOR: Splits shallowest leaf (window-1 or window-2 at depth 1)
// Root remains column, but creates row split at the leaf
expect(workspace.layout).toMatchObject({
direction: "row",
direction: "column",
});
// The first child should now be a row split containing the original window and new window
const layout = workspace.layout as any;
expect(layout.first).toHaveProperty("direction", "row");
});
});
@@ -514,12 +595,19 @@ describe("addWindow", () => {
});
it("should preserve existing windows when adding new one", () => {
const state = createTestState({
insertionMode: "row",
splitPercentage: 50,
insertionPosition: "second",
}, "window-1");
state.windows["window-1"] = { id: "window-1", appId: "profile", props: {} };
const state = createTestState(
{
insertionMode: "row",
splitPercentage: 50,
insertionPosition: "second",
},
"window-1",
);
state.windows["window-1"] = {
id: "window-1",
appId: "profile",
props: {},
};
state.workspaces["test-workspace"].windowIds = ["window-1"];
const result = addWindow(state, {

View File

@@ -1,6 +1,11 @@
import { v4 as uuidv4 } from "uuid";
import type { MosaicNode } from "react-mosaic-component";
import { GrimoireState, WindowInstance, UserRelays } from "@/types/app";
import {
GrimoireState,
WindowInstance,
UserRelays,
LayoutConfig,
} from "@/types/app";
import { insertWindow } from "@/lib/layout-utils";
import { applyPresetToLayout, type LayoutPreset } from "@/lib/layout-presets";
@@ -48,6 +53,12 @@ export const createWorkspace = (
label,
layout: null,
windowIds: [],
layoutConfig: {
insertionMode: "smart",
splitPercentage: 50,
insertionPosition: "second",
autoPreset: undefined,
},
},
},
};
@@ -76,8 +87,8 @@ export const addWindow = (
commandString: payload.commandString,
};
// Insert window using global layout configuration
const newLayout = insertWindow(ws.layout, newWindowId, state.layoutConfig);
// Insert window using workspace's layout configuration
const newLayout = insertWindow(ws.layout, newWindowId, ws.layoutConfig);
return {
...state,
@@ -347,18 +358,27 @@ export const updateWindow = (
};
/**
* Updates the global layout configuration.
* Controls how new windows are inserted into all workspaces.
* Updates the active workspace's layout configuration.
* Controls how new windows are inserted into the active workspace.
*/
export const updateLayoutConfig = (
state: GrimoireState,
layoutConfig: Partial<GrimoireState["layoutConfig"]>,
layoutConfig: Partial<LayoutConfig>,
): GrimoireState => {
const activeId = state.activeWorkspaceId;
const activeWorkspace = state.workspaces[activeId];
return {
...state,
layoutConfig: {
...state.layoutConfig,
...layoutConfig,
workspaces: {
...state.workspaces,
[activeId]: {
...activeWorkspace,
layoutConfig: {
...activeWorkspace.layoutConfig,
...layoutConfig,
},
},
},
};
};

View File

@@ -1,7 +1,12 @@
import { useEffect, useCallback } from "react";
import { useAtom } from "jotai";
import { atomWithStorage, createJSONStorage } from "jotai/utils";
import { GrimoireState, AppId, WindowInstance } from "@/types/app";
import {
GrimoireState,
AppId,
WindowInstance,
LayoutConfig,
} from "@/types/app";
import { useLocale } from "@/hooks/useLocale";
import * as Logic from "./logic";
import { CURRENT_VERSION, validateState, migrateState } from "@/lib/migrations";
@@ -18,14 +23,14 @@ const initialState: GrimoireState = {
number: 1,
windowIds: [],
layout: null,
layoutConfig: {
insertionMode: "smart", // Smart auto-balancing by default
splitPercentage: 50, // Equal split
insertionPosition: "second", // New windows on right/bottom
autoPreset: undefined, // No preset maintenance
},
},
},
layoutConfig: {
insertionMode: "smart", // Smart auto-balancing by default
splitPercentage: 50, // Equal split
insertionPosition: "second", // New windows on right/bottom
autoPreset: undefined, // No preset maintenance
},
};
// Custom storage with error handling and migrations
@@ -230,14 +235,13 @@ export const useGrimoire = () => {
);
const updateLayoutConfig = useCallback(
(layoutConfig: Partial<GrimoireState["layoutConfig"]>) =>
(layoutConfig: Partial<LayoutConfig>) =>
setState((prev) => Logic.updateLayoutConfig(prev, layoutConfig)),
[setState],
);
const applyPresetLayout = useCallback(
(preset: any) =>
setState((prev) => Logic.applyPresetLayout(prev, preset)),
(preset: any) => setState((prev) => Logic.applyPresetLayout(prev, preset)),
[setState],
);

View File

@@ -180,13 +180,7 @@ describe("layout-presets", () => {
});
it("handles 5 windows (main + 4 sidebars)", () => {
const layout = mainSidebarPreset.generate([
"w1",
"w2",
"w3",
"w4",
"w5",
]);
const layout = mainSidebarPreset.generate(["w1", "w2", "w3", "w4", "w5"]);
const windowIds = collectWindowIds(layout);
expect(windowIds).toEqual(["w1", "w2", "w3", "w4", "w5"]);
// First window is main, rest are stacked vertically
@@ -203,9 +197,9 @@ describe("layout-presets", () => {
describe("applyPresetToLayout", () => {
it("throws error if too few windows", () => {
const layout: MosaicNode<string> = "w1";
expect(() =>
applyPresetToLayout(layout, BUILT_IN_PRESETS.grid)
).toThrow("at least 2 windows");
expect(() => applyPresetToLayout(layout, BUILT_IN_PRESETS.grid)).toThrow(
"at least 2 windows",
);
});
it("applies grid preset to existing layout", () => {

View File

@@ -30,7 +30,7 @@ function buildHorizontalRow(windowIds: string[]): MosaicNode<string> {
}
// Calculate percentage for first window to make equal splits
const splitPercent = (100 / windowIds.length);
const splitPercent = 100 / windowIds.length;
return {
direction: "row",
@@ -52,7 +52,7 @@ function buildVerticalStack(windowIds: string[]): MosaicNode<string> {
}
// Calculate percentage for first window to make equal splits
const splitPercent = (100 / windowIds.length);
const splitPercent = 100 / windowIds.length;
return {
direction: "column",
@@ -62,11 +62,38 @@ function buildVerticalStack(windowIds: string[]): MosaicNode<string> {
};
}
/**
* Builds a vertical stack of MosaicNodes with equal splits
*/
function buildVerticalStackOfNodes(
nodes: MosaicNode<string>[],
): MosaicNode<string> {
if (nodes.length === 0) {
throw new Error("Cannot build stack with zero nodes");
}
if (nodes.length === 1) {
return nodes[0];
}
// Calculate percentage for first node to make equal splits
const splitPercent = 100 / nodes.length;
return {
direction: "column",
first: nodes[0],
second: buildVerticalStackOfNodes(nodes.slice(1)),
splitPercentage: splitPercent,
};
}
/**
* Calculates best grid dimensions for N windows
* Prefers square-ish grids, slightly favoring more columns than rows
*/
function calculateGridDimensions(windowCount: number): { rows: number; cols: number } {
function calculateGridDimensions(windowCount: number): {
rows: number;
cols: number;
} {
const sqrt = Math.sqrt(windowCount);
const rows = Math.floor(sqrt);
const cols = Math.ceil(windowCount / rows);
@@ -95,16 +122,16 @@ function buildGridLayout(windowIds: string[]): MosaicNode<string> {
return windowIds[0];
}
const { rows, cols } = calculateGridDimensions(windowIds.length);
const { cols } = calculateGridDimensions(windowIds.length);
// Split windows into rows
const rowChunks = chunkArray(windowIds, cols);
// Build each row as a horizontal split
const rowNodes = rowChunks.map(chunk => buildHorizontalRow(chunk));
const rowNodes = rowChunks.map((chunk) => buildHorizontalRow(chunk));
// Stack rows vertically
return buildVerticalStack(rowNodes);
return buildVerticalStackOfNodes(rowNodes);
}
/**
@@ -156,9 +183,7 @@ export const BUILT_IN_PRESETS: Record<string, LayoutPreset> = {
/**
* Collects window IDs from a layout tree in depth-first order
*/
export function collectWindowIds(
layout: MosaicNode<string> | null
): string[] {
export function collectWindowIds(layout: MosaicNode<string> | null): string[] {
if (layout === null) {
return [];
}
@@ -167,7 +192,10 @@ export function collectWindowIds(
return [layout];
}
return [...collectWindowIds(layout.first), ...collectWindowIds(layout.second)];
return [
...collectWindowIds(layout.first),
...collectWindowIds(layout.second),
];
}
/**
@@ -176,7 +204,7 @@ export function collectWindowIds(
*/
export function applyPresetToLayout(
currentLayout: MosaicNode<string> | null,
preset: LayoutPreset
preset: LayoutPreset,
): MosaicNode<string> {
// Collect all window IDs from current layout
const windowIds = collectWindowIds(currentLayout);
@@ -184,14 +212,14 @@ export function applyPresetToLayout(
// Check minimum requirement
if (windowIds.length < preset.minSlots) {
throw new Error(
`Preset "${preset.name}" requires at least ${preset.minSlots} windows but only ${windowIds.length} available`
`Preset "${preset.name}" requires at least ${preset.minSlots} windows but only ${windowIds.length} available`,
);
}
// Check maximum limit if defined
if (preset.maxSlots && windowIds.length > preset.maxSlots) {
throw new Error(
`Preset "${preset.name}" supports maximum ${preset.maxSlots} windows but ${windowIds.length} available`
`Preset "${preset.name}" supports maximum ${preset.maxSlots} windows but ${windowIds.length} available`,
);
}

View File

@@ -1,10 +1,6 @@
import { describe, it, expect } from "vitest";
import type { MosaicNode } from "react-mosaic-component";
import {
analyzeLayoutStats,
insertWindow,
type LayoutStats,
} from "./layout-utils";
import { analyzeLayoutStats, insertWindow } from "./layout-utils";
import type { LayoutConfig } from "@/types/app";
describe("analyzeLayoutStats", () => {

View File

@@ -32,7 +32,7 @@ export interface LeafInfo {
* Used by smart direction algorithm to balance splits
*/
export function analyzeLayoutStats(
node: MosaicNode<string> | null
node: MosaicNode<string> | null,
): LayoutStats {
if (node === null) {
return { rowSplits: 0, columnSplits: 0, depth: 0, windowCount: 0 };
@@ -67,7 +67,7 @@ export function analyzeLayoutStats(
export function findAllLeaves(
node: MosaicNode<string> | null,
depth: number = 0,
parentDirection: "row" | "column" | null = null
parentDirection: "row" | "column" | null = null,
): LeafInfo[] {
if (node === null) {
return [];
@@ -90,7 +90,7 @@ export function findAllLeaves(
* If multiple leaves at same depth, returns first one encountered
*/
export function findShallowstLeaf(
node: MosaicNode<string> | null
node: MosaicNode<string> | null,
): LeafInfo | null {
const leaves = findAllLeaves(node);
@@ -120,7 +120,7 @@ export function replaceLeafWithSplit(
newWindowId: string,
direction: "row" | "column",
splitPercentage: number,
position: "first" | "second" = "second"
position: "first" | "second" = "second",
): MosaicNode<string> | null {
if (node === null) return null;
@@ -151,7 +151,7 @@ export function replaceLeafWithSplit(
newWindowId,
direction,
splitPercentage,
position
position,
);
const newSecond = replaceLeafWithSplit(
node.second,
@@ -159,7 +159,7 @@ export function replaceLeafWithSplit(
newWindowId,
direction,
splitPercentage,
position
position,
);
// Return new branch with potentially updated children
@@ -177,7 +177,7 @@ export function replaceLeafWithSplit(
* This creates a checkerboard pattern for more balanced layouts
*/
export function calculateBalancedDirection(
parentDirection: "row" | "column" | null
parentDirection: "row" | "column" | null,
): "row" | "column" {
if (parentDirection === null) {
return "row"; // Default to horizontal for first split
@@ -203,7 +203,7 @@ export function calculateBalancedDirection(
export function insertWindow(
currentLayout: MosaicNode<string> | null,
newWindowId: string,
config: LayoutConfig
config: LayoutConfig,
): MosaicNode<string> {
// First window - just return the window ID as leaf node
if (currentLayout === null) {
@@ -236,7 +236,7 @@ export function insertWindow(
newWindowId,
direction,
config.splitPercentage,
config.insertionPosition
config.insertionPosition,
);
return newLayout || newWindowId; // Fallback if replacement failed

View File

@@ -35,19 +35,22 @@ describe("migrations", () => {
expect(migrated.workspaces.ws2.number).toBe(2);
expect(migrated.workspaces.ws2.label).toBeUndefined();
// v7→v8→v9: layoutConfig moved to global state
expect(migrated.layoutConfig).toEqual({
// v7→v8→v9: layoutConfig added to each workspace
expect(migrated.workspaces.ws1.layoutConfig).toEqual({
insertionMode: "smart",
splitPercentage: 50,
insertionPosition: "second",
autoPreset: undefined,
});
expect(migrated.workspaces.ws2.layoutConfig).toEqual({
insertionMode: "smart",
splitPercentage: 50,
insertionPosition: "second",
autoPreset: undefined,
});
// Workspaces should NOT have layoutConfig
expect(migrated.workspaces.ws1.layoutConfig).toBeUndefined();
expect(migrated.workspaces.ws2.layoutConfig).toBeUndefined();
});
it("should convert non-numeric labels to number with label and add global layoutConfig", () => {
it("should convert non-numeric labels to number with label and add per-workspace layoutConfig", () => {
const oldState = {
__version: 6,
windows: {},
@@ -78,13 +81,12 @@ describe("migrations", () => {
expect(migrated.workspaces.ws2.number).toBe(2);
expect(migrated.workspaces.ws2.label).toBe("Development");
// v7→v8→v9: layoutConfig is global, not per-workspace
expect(migrated.layoutConfig).toBeDefined();
expect(migrated.workspaces.ws1.layoutConfig).toBeUndefined();
expect(migrated.workspaces.ws2.layoutConfig).toBeUndefined();
// v7→v8→v9: layoutConfig added to each workspace
expect(migrated.workspaces.ws1.layoutConfig).toBeDefined();
expect(migrated.workspaces.ws2.layoutConfig).toBeDefined();
});
it("should handle mixed numeric and text labels and add global layoutConfig", () => {
it("should handle mixed numeric and text labels and add per-workspace layoutConfig", () => {
const oldState = {
__version: 6,
windows: {},
@@ -123,11 +125,10 @@ describe("migrations", () => {
expect(migrated.workspaces.ws3.number).toBe(3);
expect(migrated.workspaces.ws3.label).toBeUndefined();
// v7→v8→v9: layoutConfig is global
expect(migrated.layoutConfig).toBeDefined();
expect(migrated.workspaces.ws1.layoutConfig).toBeUndefined();
expect(migrated.workspaces.ws2.layoutConfig).toBeUndefined();
expect(migrated.workspaces.ws3.layoutConfig).toBeUndefined();
// v7→v8→v9: layoutConfig added to each workspace
expect(migrated.workspaces.ws1.layoutConfig).toBeDefined();
expect(migrated.workspaces.ws2.layoutConfig).toBeDefined();
expect(migrated.workspaces.ws3.layoutConfig).toBeDefined();
});
it("should validate migrated state", () => {
@@ -151,7 +152,7 @@ describe("migrations", () => {
});
describe("v8 to v9 migration", () => {
it("should move layoutConfig from workspaces to global state", () => {
it("should preserve per-workspace layoutConfig", () => {
const v8State = {
__version: 8,
windows: {
@@ -177,7 +178,12 @@ describe("migrations", () => {
id: "ws2",
number: 2,
label: "Development",
layout: { direction: "row", first: "w1", second: "w2", splitPercentage: 50 },
layout: {
direction: "row",
first: "w1",
second: "w2",
splitPercentage: 50,
},
windowIds: ["w1", "w2"],
layoutConfig: {
insertionMode: "row",
@@ -193,17 +199,19 @@ describe("migrations", () => {
expect(migrated.__version).toBe(9);
// layoutConfig should be at global level (from first workspace)
expect(migrated.layoutConfig).toEqual({
// layoutConfig should remain per-workspace
expect(migrated.workspaces.ws1.layoutConfig).toEqual({
insertionMode: "smart",
splitPercentage: 50,
insertionPosition: "second",
autoPreset: undefined,
});
// Workspaces should NOT have layoutConfig
expect(migrated.workspaces.ws1.layoutConfig).toBeUndefined();
expect(migrated.workspaces.ws2.layoutConfig).toBeUndefined();
expect(migrated.workspaces.ws2.layoutConfig).toEqual({
insertionMode: "row",
splitPercentage: 70,
insertionPosition: "first",
autoPreset: undefined,
});
// Other fields should be preserved
expect(migrated.workspaces.ws2.label).toBe("Development");

View File

@@ -73,8 +73,9 @@ const migrations: Record<number, MigrationFn> = {
// Add default layoutConfig to each workspace
for (const [id, workspace] of Object.entries(state.workspaces || {})) {
const ws = workspace as Record<string, any>;
migratedWorkspaces[id] = {
...workspace,
...ws,
layoutConfig: {
insertionMode: "smart", // New smart default (auto-balance)
splitPercentage: 50, // Matches old 50/50 behavior
@@ -90,30 +91,28 @@ const migrations: Record<number, MigrationFn> = {
workspaces: migratedWorkspaces,
};
},
// Migration from v8 to v9 - moves layoutConfig from per-workspace to global
// Migration from v8 to v9 - preserve per-workspace layoutConfig
8: (state: any) => {
// Ensure all workspaces have layoutConfig (add default if missing)
const migratedWorkspaces: Record<string, any> = {};
// Get layoutConfig from first workspace (or use default)
const firstWorkspace = Object.values(state.workspaces || {})[0] as any;
const layoutConfig = firstWorkspace?.layoutConfig || {
insertionMode: "smart",
splitPercentage: 50,
insertionPosition: "second",
autoPreset: undefined,
};
// Remove layoutConfig from all workspaces
for (const [id, workspace] of Object.entries(state.workspaces || {})) {
const { layoutConfig: _, ...workspaceWithoutConfig } = workspace as any;
migratedWorkspaces[id] = workspaceWithoutConfig;
const ws = workspace as Record<string, any>;
migratedWorkspaces[id] = {
...ws,
layoutConfig: ws.layoutConfig || {
insertionMode: "smart",
splitPercentage: 50,
insertionPosition: "second",
autoPreset: undefined,
},
};
}
return {
...state,
__version: 9,
workspaces: migratedWorkspaces,
layoutConfig, // Move to global state
};
},
};
@@ -134,7 +133,6 @@ export function validateState(state: any): state is GrimoireState {
!state.windows ||
!state.workspaces ||
!state.activeWorkspaceId ||
!state.layoutConfig ||
typeof state.__version !== "number"
) {
return false;
@@ -156,11 +154,17 @@ export function validateState(state: any): state is GrimoireState {
}
// All window IDs in workspaces must exist in windows
// Each workspace must have layoutConfig
for (const workspace of Object.values(state.workspaces)) {
if (!Array.isArray((workspace as any).windowIds)) {
const ws = workspace as any;
if (!Array.isArray(ws.windowIds)) {
return false;
}
for (const windowId of (workspace as any).windowIds) {
// Verify workspace has layoutConfig
if (!ws.layoutConfig || typeof ws.layoutConfig !== "object") {
return false;
}
for (const windowId of ws.windowIds) {
if (!state.windows[windowId]) {
return false;
}

View File

@@ -64,6 +64,7 @@ export interface Workspace {
label?: string; // Optional user-editable label
layout: MosaicNode<string> | null;
windowIds: string[];
layoutConfig: LayoutConfig; // Per-workspace configuration for window insertion
}
export interface RelayInfo {
@@ -83,7 +84,6 @@ export interface GrimoireState {
windows: Record<string, WindowInstance>;
workspaces: Record<string, Workspace>;
activeWorkspaceId: string;
layoutConfig: LayoutConfig; // Global configuration for window insertion (applies to all workspaces)
activeAccount?: {
pubkey: string;
relays?: UserRelays;