diff --git a/src/components/KindsViewer.tsx b/src/components/KindsViewer.tsx index 4927bb0..56f267f 100644 --- a/src/components/KindsViewer.tsx +++ b/src/components/KindsViewer.tsx @@ -18,58 +18,55 @@ export default function KindsViewer() { {/* Header */}
-

- Supported Event Kinds ({sortedKinds.length}) -

-

- Event kinds with rich rendering support in Grimoire. Default kinds - display raw content only. -

-
+

+ Supported Event Kinds ({sortedKinds.length}) +

+

+ Event kinds with rich rendering support in Grimoire. Default kinds + display raw content only. +

+ - {/* Kind List */} -
- {sortedKinds.map((kind) => { - const kindInfo = getKindInfo(kind); - const Icon = kindInfo?.icon; + {/* Kind List */} +
+ {sortedKinds.map((kind) => { + const kindInfo = getKindInfo(kind); + const Icon = kindInfo?.icon; - return ( -
-
- {/* Icon */} -
- {Icon ? ( - - ) : ( - - {kind} - - )} -
- - {/* Content */} -
-
- - {kind} - - - {kindInfo?.name || `Kind ${kind}`} - -
-

- {kindInfo?.description || "No description available"} -

- {kindInfo?.nip && } + return ( +
+
+ {/* Icon */} +
+ {Icon ? ( + + ) : ( + + {kind} + + )} +
+ + {/* Content */} +
+
+ + {kind} + + + {kindInfo?.name || `Kind ${kind}`} +
+

+ {kindInfo?.description || "No description available"} +

+ {kindInfo?.nip && }
- ); - })} -
+
+ ); + })} +
); } diff --git a/src/components/LayoutControls.tsx b/src/components/LayoutControls.tsx index e6f7a19..77b5a00 100644 --- a/src/components/LayoutControls.tsx +++ b/src/components/LayoutControls.tsx @@ -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; diff --git a/src/components/NipRenderer.tsx b/src/components/NipRenderer.tsx index c819eb0..76f6428 100644 --- a/src/components/NipRenderer.tsx +++ b/src/components/NipRenderer.tsx @@ -16,7 +16,9 @@ export function NipRenderer({ nipId, className = "" }: NipRendererProps) { if (loading) { return ( - + Loading NIP-{nipId}... ); diff --git a/src/components/NipsViewer.tsx b/src/components/NipsViewer.tsx index 15e4c6b..de49749 100644 --- a/src/components/NipsViewer.tsx +++ b/src/components/NipsViewer.tsx @@ -68,60 +68,60 @@ export default function NipsViewer() { {/* Header */}
-

- {search - ? `Showing ${filteredNips.length} of ${sortedNips.length} NIPs` - : `Nostr Implementation Possibilities (${sortedNips.length})`} -

-

- Protocol specifications and extensions for the Nostr network. Click - any NIP to view its full specification document. -

+

+ {search + ? `Showing ${filteredNips.length} of ${sortedNips.length} NIPs` + : `Nostr Implementation Possibilities (${sortedNips.length})`} +

+

+ Protocol specifications and extensions for the Nostr network. Click + any NIP to view its full specification document. +

- {/* Search Input */} -
- - setSearch(e.target.value)} - onKeyDown={handleKeyDown} - className="pl-9 pr-9" - /> - {search && ( - - )} -
+ {/* Search Input */} +
+ + setSearch(e.target.value)} + onKeyDown={handleKeyDown} + className="pl-9 pr-9" + /> + {search && ( + + )}
+
- {/* NIP List */} - {filteredNips.length > 0 ? ( -
- {filteredNips.map((nipId) => ( - - ))} -
- ) : ( -
-

No NIPs match "{search}"

-

Try searching for a different term

-
- )} + {/* NIP List */} + {filteredNips.length > 0 ? ( +
+ {filteredNips.map((nipId) => ( + + ))} +
+ ) : ( +
+

No NIPs match "{search}"

+

Try searching for a different term

+
+ )}
); } diff --git a/src/components/WorkspaceSettings.tsx b/src/components/WorkspaceSettings.tsx index db7b77c..faa9880 100644 --- a/src/components/WorkspaceSettings.tsx +++ b/src/components/WorkspaceSettings.tsx @@ -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", )} > diff --git a/src/components/nostr/kinds/LiveActivityDetailRenderer.tsx b/src/components/nostr/kinds/LiveActivityDetailRenderer.tsx index bc9a8e9..15fd210 100644 --- a/src/components/nostr/kinds/LiveActivityDetailRenderer.tsx +++ b/src/components/nostr/kinds/LiveActivityDetailRenderer.tsx @@ -80,10 +80,7 @@ export function LiveActivityDetailRenderer({
{/* Host */} - + {/* Description */} {activity.summary && ( diff --git a/src/components/ui/CenteredContent.tsx b/src/components/ui/CenteredContent.tsx index ffcf591..990fc76 100644 --- a/src/components/ui/CenteredContent.tsx +++ b/src/components/ui/CenteredContent.tsx @@ -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} diff --git a/src/components/ui/popover.tsx b/src/components/ui/popover.tsx index f906c34..73cad83 100644 --- a/src/components/ui/popover.tsx +++ b/src/components/ui/popover.tsx @@ -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} /> diff --git a/src/components/ui/slider.tsx b/src/components/ui/slider.tsx index 9398b33..08afe57 100644 --- a/src/components/ui/slider.tsx +++ b/src/components/ui/slider.tsx @@ -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, @@ -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< -)) -Slider.displayName = SliderPrimitive.Root.displayName +)); +Slider.displayName = SliderPrimitive.Root.displayName; -export { Slider } +export { Slider }; diff --git a/src/core/logic.test.ts b/src/core/logic.test.ts index 30221a1..ea8aaee 100644 --- a/src/core/logic.test.ts +++ b/src/core/logic.test.ts @@ -130,17 +130,20 @@ describe("findLowestAvailableWorkspaceNumber", () => { describe("addWindow", () => { // Helper to create minimal test state - const createTestState = (layoutConfig: LayoutConfig, existingLayout: MosaicNode | null = null): GrimoireState => ({ + const createTestState = ( + layoutConfig: LayoutConfig, + existingLayout: MosaicNode | 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, { diff --git a/src/core/logic.ts b/src/core/logic.ts index a719d6a..f0a94db 100644 --- a/src/core/logic.ts +++ b/src/core/logic.ts @@ -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, + layoutConfig: Partial, ): 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, + }, + }, }, }; }; diff --git a/src/core/state.ts b/src/core/state.ts index 609bfd3..ea5a25f 100644 --- a/src/core/state.ts +++ b/src/core/state.ts @@ -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) => + (layoutConfig: Partial) => 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], ); diff --git a/src/lib/layout-presets.test.ts b/src/lib/layout-presets.test.ts index adfac1d..a804cb0 100644 --- a/src/lib/layout-presets.test.ts +++ b/src/lib/layout-presets.test.ts @@ -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 = "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", () => { diff --git a/src/lib/layout-presets.ts b/src/lib/layout-presets.ts index 8930b9b..efc7d8a 100644 --- a/src/lib/layout-presets.ts +++ b/src/lib/layout-presets.ts @@ -30,7 +30,7 @@ function buildHorizontalRow(windowIds: string[]): MosaicNode { } // 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 { } // 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 { }; } +/** + * Builds a vertical stack of MosaicNodes with equal splits + */ +function buildVerticalStackOfNodes( + nodes: MosaicNode[], +): MosaicNode { + 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 { 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 = { /** * Collects window IDs from a layout tree in depth-first order */ -export function collectWindowIds( - layout: MosaicNode | null -): string[] { +export function collectWindowIds(layout: MosaicNode | 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 | null, - preset: LayoutPreset + preset: LayoutPreset, ): MosaicNode { // 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`, ); } diff --git a/src/lib/layout-utils.test.ts b/src/lib/layout-utils.test.ts index b12daab..b225b10 100644 --- a/src/lib/layout-utils.test.ts +++ b/src/lib/layout-utils.test.ts @@ -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", () => { diff --git a/src/lib/layout-utils.ts b/src/lib/layout-utils.ts index 450d89f..0b28d54 100644 --- a/src/lib/layout-utils.ts +++ b/src/lib/layout-utils.ts @@ -32,7 +32,7 @@ export interface LeafInfo { * Used by smart direction algorithm to balance splits */ export function analyzeLayoutStats( - node: MosaicNode | null + node: MosaicNode | 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 | 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 | null + node: MosaicNode | 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 | 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 | null, newWindowId: string, - config: LayoutConfig + config: LayoutConfig, ): MosaicNode { // 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 diff --git a/src/lib/migrations.test.ts b/src/lib/migrations.test.ts index b4ff868..9e15172 100644 --- a/src/lib/migrations.test.ts +++ b/src/lib/migrations.test.ts @@ -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"); diff --git a/src/lib/migrations.ts b/src/lib/migrations.ts index 305bd34..4bbb7e4 100644 --- a/src/lib/migrations.ts +++ b/src/lib/migrations.ts @@ -73,8 +73,9 @@ const migrations: Record = { // Add default layoutConfig to each workspace for (const [id, workspace] of Object.entries(state.workspaces || {})) { + const ws = workspace as Record; 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 = { 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 = {}; - // 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; + 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; } diff --git a/src/types/app.ts b/src/types/app.ts index add17fd..d1d1905 100644 --- a/src/types/app.ts +++ b/src/types/app.ts @@ -64,6 +64,7 @@ export interface Workspace { label?: string; // Optional user-editable label layout: MosaicNode | null; windowIds: string[]; + layoutConfig: LayoutConfig; // Per-workspace configuration for window insertion } export interface RelayInfo { @@ -83,7 +84,6 @@ export interface GrimoireState { windows: Record; workspaces: Record; activeWorkspaceId: string; - layoutConfig: LayoutConfig; // Global configuration for window insertion (applies to all workspaces) activeAccount?: { pubkey: string; relays?: UserRelays;