diff --git a/src/core/logic.test.ts b/src/core/logic.test.ts index 381a75b..1a35403 100644 --- a/src/core/logic.test.ts +++ b/src/core/logic.test.ts @@ -1,5 +1,7 @@ import { describe, it, expect } from "vitest"; -import { findLowestAvailableWorkspaceNumber } from "./logic"; +import type { MosaicNode } from "react-mosaic-component"; +import { findLowestAvailableWorkspaceNumber, addWindow } from "./logic"; +import type { GrimoireState, LayoutConfig } from "@/types/app"; describe("findLowestAvailableWorkspaceNumber", () => { describe("basic number assignment", () => { @@ -125,3 +127,408 @@ describe("findLowestAvailableWorkspaceNumber", () => { }); }); }); + +describe("addWindow", () => { + // Helper to create minimal test state + const createTestState = (layoutConfig: LayoutConfig, existingLayout: MosaicNode | null = null): GrimoireState => ({ + __version: 8, + windows: {}, + activeWorkspaceId: "test-workspace", + workspaces: { + "test-workspace": { + id: "test-workspace", + number: 1, + windowIds: [], + layout: existingLayout, + layoutConfig, + }, + }, + }); + + describe("first window", () => { + it("should create first window with row config", () => { + const state = createTestState({ + insertionMode: "row", + splitPercentage: 50, + insertionPosition: "second", + }); + + const result = addWindow(state, { + appId: "profile", + props: { npub: "test" }, + }); + + const workspace = result.workspaces["test-workspace"]; + expect(workspace.windowIds).toHaveLength(1); + expect(workspace.layout).toBe(workspace.windowIds[0]); // Single window = leaf node + expect(result.windows[workspace.windowIds[0]]).toEqual({ + id: workspace.windowIds[0], + appId: "profile", + customTitle: undefined, + props: { npub: "test" }, + commandString: undefined, + }); + }); + + it("should create first window with column config", () => { + const state = createTestState({ + insertionMode: "column", + splitPercentage: 50, + insertionPosition: "second", + }); + + const result = addWindow(state, { + appId: "nip", + props: { number: "01" }, + }); + + const workspace = result.workspaces["test-workspace"]; + expect(workspace.windowIds).toHaveLength(1); + expect(workspace.layout).toBe(workspace.windowIds[0]); + }); + + it("should create first window with smart config", () => { + const state = createTestState({ + insertionMode: "smart", + splitPercentage: 50, + insertionPosition: "second", + }); + + const result = addWindow(state, { + appId: "kinds", + props: {}, + }); + + const workspace = result.workspaces["test-workspace"]; + expect(workspace.windowIds).toHaveLength(1); + expect(workspace.layout).toBe(workspace.windowIds[0]); + }); + }); + + 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: {} }; + state.workspaces["test-workspace"].windowIds = ["window-1"]; + + const result = addWindow(state, { + appId: "nip", + props: { number: "01" }, + }); + + const workspace = result.workspaces["test-workspace"]; + expect(workspace.windowIds).toHaveLength(2); + expect(workspace.layout).toMatchObject({ + direction: "row", + splitPercentage: 50, + }); + }); + + 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: {} }; + state.workspaces["test-workspace"].windowIds = ["window-1"]; + + const result = addWindow(state, { + appId: "nip", + props: { number: "01" }, + }); + + const workspace = result.workspaces["test-workspace"]; + expect(workspace.layout).toMatchObject({ + direction: "row", + splitPercentage: 70, + }); + }); + + 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: {} }; + state.workspaces["test-workspace"].windowIds = ["window-1"]; + + const result = addWindow(state, { + appId: "nip", + props: { number: "01" }, + }); + + const workspace = result.workspaces["test-workspace"]; + const layout = workspace.layout as any; + expect(layout.first).toBe("window-1"); + expect(layout.second).toBe(workspace.windowIds[1]); + }); + + 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: {} }; + state.workspaces["test-workspace"].windowIds = ["window-1"]; + + const result = addWindow(state, { + appId: "nip", + props: { number: "01" }, + }); + + const workspace = result.workspaces["test-workspace"]; + const layout = workspace.layout as any; + expect(layout.first).toBe(workspace.windowIds[1]); // New window + expect(layout.second).toBe("window-1"); // Old window + }); + }); + + 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: {} }; + state.workspaces["test-workspace"].windowIds = ["window-1"]; + + const result = addWindow(state, { + appId: "nip", + props: { number: "01" }, + }); + + const workspace = result.workspaces["test-workspace"]; + expect(workspace.windowIds).toHaveLength(2); + expect(workspace.layout).toMatchObject({ + direction: "column", + splitPercentage: 50, + }); + }); + + 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: {} }; + state.workspaces["test-workspace"].windowIds = ["window-1"]; + + const result = addWindow(state, { + appId: "nip", + props: { number: "01" }, + }); + + const workspace = result.workspaces["test-workspace"]; + const layout = workspace.layout as any; + expect(layout.first).toBe("window-1"); + expect(layout.second).toBe(workspace.windowIds[1]); + }); + + 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: {} }; + state.workspaces["test-workspace"].windowIds = ["window-1"]; + + const result = addWindow(state, { + appId: "nip", + props: { number: "01" }, + }); + + const workspace = result.workspaces["test-workspace"]; + const layout = workspace.layout as any; + expect(layout.first).toBe(workspace.windowIds[1]); // New window + expect(layout.second).toBe("window-1"); // Old window + }); + }); + + 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: {} }; + state.workspaces["test-workspace"].windowIds = ["window-1"]; + + const result = addWindow(state, { + appId: "nip", + props: { number: "01" }, + }); + + const workspace = result.workspaces["test-workspace"]; + expect(workspace.layout).toMatchObject({ + direction: "row", // Smart defaults to row for first split + }); + }); + }); + + describe("third window with smart config", () => { + it("should balance by adding vertical split when horizontal exists", () => { + // Start with horizontal split (window-1 | window-2) + const existingLayout: MosaicNode = { + direction: "row", + first: "window-1", + 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: {} }; + state.windows["window-2"] = { id: "window-2", appId: "nip", props: {} }; + state.workspaces["test-workspace"].windowIds = ["window-1", "window-2"]; + + const result = addWindow(state, { + appId: "kinds", + props: {}, + }); + + const workspace = result.workspaces["test-workspace"]; + // Should add column split to balance (1 row, 0 column → add column) + expect(workspace.layout).toMatchObject({ + direction: "column", + }); + }); + + it("should balance by adding horizontal split when vertical exists", () => { + // Start with vertical split (window-1 / window-2) + const existingLayout: MosaicNode = { + direction: "column", + first: "window-1", + 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: {} }; + state.windows["window-2"] = { id: "window-2", appId: "nip", props: {} }; + state.workspaces["test-workspace"].windowIds = ["window-1", "window-2"]; + + const result = addWindow(state, { + appId: "kinds", + props: {}, + }); + + const workspace = result.workspaces["test-workspace"]; + // Should add row split to balance (0 row, 1 column → add row) + expect(workspace.layout).toMatchObject({ + direction: "row", + }); + }); + }); + + describe("window metadata", () => { + it("should store commandString when provided", () => { + const state = createTestState({ + insertionMode: "row", + splitPercentage: 50, + insertionPosition: "second", + }); + + const result = addWindow(state, { + appId: "profile", + props: { npub: "test" }, + commandString: "profile alice@nostr.com", + }); + + const workspace = result.workspaces["test-workspace"]; + const window = result.windows[workspace.windowIds[0]]; + expect(window.commandString).toBe("profile alice@nostr.com"); + }); + + it("should store customTitle when provided", () => { + const state = createTestState({ + insertionMode: "row", + splitPercentage: 50, + insertionPosition: "second", + }); + + const result = addWindow(state, { + appId: "profile", + props: { npub: "test" }, + customTitle: "Alice Profile", + }); + + const workspace = result.workspaces["test-workspace"]; + const window = result.windows[workspace.windowIds[0]]; + expect(window.customTitle).toBe("Alice Profile"); + }); + + it("should store both commandString and customTitle", () => { + const state = createTestState({ + insertionMode: "row", + splitPercentage: 50, + insertionPosition: "second", + }); + + const result = addWindow(state, { + appId: "profile", + props: { npub: "test" }, + commandString: "profile alice@nostr.com", + customTitle: "Alice", + }); + + const workspace = result.workspaces["test-workspace"]; + const window = result.windows[workspace.windowIds[0]]; + expect(window.commandString).toBe("profile alice@nostr.com"); + expect(window.customTitle).toBe("Alice"); + }); + }); + + describe("global windows object", () => { + it("should add window to global windows object", () => { + const state = createTestState({ + insertionMode: "row", + splitPercentage: 50, + insertionPosition: "second", + }); + + const result = addWindow(state, { + appId: "profile", + props: { npub: "test" }, + }); + + const workspace = result.workspaces["test-workspace"]; + const windowId = workspace.windowIds[0]; + expect(result.windows[windowId]).toBeDefined(); + expect(result.windows[windowId].appId).toBe("profile"); + }); + + 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: {} }; + state.workspaces["test-workspace"].windowIds = ["window-1"]; + + const result = addWindow(state, { + appId: "nip", + props: { number: "01" }, + }); + + expect(result.windows["window-1"]).toBeDefined(); + expect(Object.keys(result.windows)).toHaveLength(2); + }); + }); +}); diff --git a/src/core/logic.ts b/src/core/logic.ts index d4f6a8a..0178a19 100644 --- a/src/core/logic.ts +++ b/src/core/logic.ts @@ -1,6 +1,7 @@ import { v4 as uuidv4 } from "uuid"; import type { MosaicNode } from "react-mosaic-component"; import { GrimoireState, WindowInstance, UserRelays } from "@/types/app"; +import { insertWindow } from "@/lib/layout-utils"; /** * Finds the lowest available workspace number. @@ -74,18 +75,8 @@ export const addWindow = ( commandString: payload.commandString, }; - // Simple Binary Split Logic - let newLayout: MosaicNode; - if (ws.layout === null) { - newLayout = newWindowId; - } else { - newLayout = { - direction: "row", - first: ws.layout, - second: newWindowId, - splitPercentage: 50, - }; - } + // Insert window using workspace layout configuration + const newLayout = insertWindow(ws.layout, newWindowId, ws.layoutConfig); return { ...state, diff --git a/src/core/state.ts b/src/core/state.ts index 0895304..9155daa 100644 --- a/src/core/state.ts +++ b/src/core/state.ts @@ -18,6 +18,12 @@ 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 + }, }, }, }; diff --git a/src/lib/layout-utils.test.ts b/src/lib/layout-utils.test.ts new file mode 100644 index 0000000..a9e0879 --- /dev/null +++ b/src/lib/layout-utils.test.ts @@ -0,0 +1,557 @@ +import { describe, it, expect } from "vitest"; +import type { MosaicNode } from "react-mosaic-component"; +import { + analyzeLayoutStats, + calculateSmartDirection, + insertWindow, + type LayoutStats, +} from "./layout-utils"; +import type { LayoutConfig } from "@/types/app"; + +describe("analyzeLayoutStats", () => { + describe("empty and single window layouts", () => { + it("should return zeros for null layout", () => { + const result = analyzeLayoutStats(null); + expect(result).toEqual({ + rowSplits: 0, + columnSplits: 0, + depth: 0, + windowCount: 0, + }); + }); + + it("should return single window stats for leaf node", () => { + const result = analyzeLayoutStats("window-1"); + expect(result).toEqual({ + rowSplits: 0, + columnSplits: 0, + depth: 0, + windowCount: 1, + }); + }); + }); + + describe("horizontal splits", () => { + it("should count single horizontal split", () => { + const layout: MosaicNode = { + direction: "row", + first: "window-1", + second: "window-2", + splitPercentage: 50, + }; + const result = analyzeLayoutStats(layout); + expect(result).toEqual({ + rowSplits: 1, + columnSplits: 0, + depth: 1, + windowCount: 2, + }); + }); + + it("should count nested horizontal splits", () => { + const layout: MosaicNode = { + direction: "row", + first: { + direction: "row", + first: "window-1", + second: "window-2", + splitPercentage: 50, + }, + second: "window-3", + splitPercentage: 50, + }; + const result = analyzeLayoutStats(layout); + expect(result).toEqual({ + rowSplits: 2, + columnSplits: 0, + depth: 2, + windowCount: 3, + }); + }); + }); + + describe("vertical splits", () => { + it("should count single vertical split", () => { + const layout: MosaicNode = { + direction: "column", + first: "window-1", + second: "window-2", + splitPercentage: 50, + }; + const result = analyzeLayoutStats(layout); + expect(result).toEqual({ + rowSplits: 0, + columnSplits: 1, + depth: 1, + windowCount: 2, + }); + }); + + it("should count nested vertical splits", () => { + const layout: MosaicNode = { + direction: "column", + first: { + direction: "column", + first: "window-1", + second: "window-2", + splitPercentage: 50, + }, + second: "window-3", + splitPercentage: 50, + }; + const result = analyzeLayoutStats(layout); + expect(result).toEqual({ + rowSplits: 0, + columnSplits: 2, + depth: 2, + windowCount: 3, + }); + }); + }); + + describe("mixed splits", () => { + it("should count mixed horizontal and vertical splits", () => { + const layout: MosaicNode = { + direction: "row", + first: { + direction: "column", + first: "window-1", + second: "window-2", + splitPercentage: 50, + }, + second: "window-3", + splitPercentage: 50, + }; + const result = analyzeLayoutStats(layout); + expect(result).toEqual({ + rowSplits: 1, + columnSplits: 1, + depth: 2, + windowCount: 3, + }); + }); + + it("should handle complex nested mixed splits", () => { + // Layout: row split with column split on left and row split on right + const layout: MosaicNode = { + direction: "row", + first: { + direction: "column", + first: "window-1", + second: "window-2", + splitPercentage: 50, + }, + second: { + direction: "row", + first: "window-3", + second: "window-4", + splitPercentage: 50, + }, + splitPercentage: 50, + }; + const result = analyzeLayoutStats(layout); + expect(result).toEqual({ + rowSplits: 2, + columnSplits: 1, + depth: 2, + windowCount: 4, + }); + }); + + it("should handle quad layout (2x2 grid)", () => { + // Quad layout: row split, each side has column split + const layout: MosaicNode = { + direction: "row", + first: { + direction: "column", + first: "window-1", + second: "window-2", + splitPercentage: 50, + }, + second: { + direction: "column", + first: "window-3", + second: "window-4", + splitPercentage: 50, + }, + splitPercentage: 50, + }; + const result = analyzeLayoutStats(layout); + expect(result).toEqual({ + rowSplits: 1, + columnSplits: 2, + depth: 2, + windowCount: 4, + }); + }); + }); + + describe("depth calculation", () => { + it("should calculate correct depth for unbalanced tree", () => { + // Deep on one side, shallow on other + const layout: MosaicNode = { + direction: "row", + first: { + direction: "row", + first: { + direction: "row", + first: "window-1", + second: "window-2", + splitPercentage: 50, + }, + second: "window-3", + splitPercentage: 50, + }, + second: "window-4", + splitPercentage: 50, + }; + const result = analyzeLayoutStats(layout); + expect(result.depth).toBe(3); + expect(result.windowCount).toBe(4); + }); + }); +}); + +describe("calculateSmartDirection", () => { + describe("null and empty layouts", () => { + it("should default to row for null layout", () => { + const result = calculateSmartDirection(null); + expect(result).toBe("row"); + }); + + it("should default to row for single window", () => { + const result = calculateSmartDirection("window-1"); + expect(result).toBe("row"); + }); + }); + + describe("balanced layouts", () => { + it("should return row when splits are equal", () => { + // 1 row split, 1 column split - equal, default to row + const layout: MosaicNode = { + direction: "row", + first: { + direction: "column", + first: "window-1", + second: "window-2", + splitPercentage: 50, + }, + second: "window-3", + splitPercentage: 50, + }; + const result = calculateSmartDirection(layout); + expect(result).toBe("row"); + }); + + it("should return row when no splits exist yet", () => { + // Just two windows with one split + const layout: MosaicNode = { + direction: "row", + first: "window-1", + second: "window-2", + splitPercentage: 50, + }; + const result = calculateSmartDirection(layout); + // 1 row split, 0 column splits -> row > column, should favor column + expect(result).toBe("column"); + }); + }); + + describe("unbalanced layouts", () => { + it("should return column when more horizontal splits exist", () => { + // 2 row splits, 0 column splits + const layout: MosaicNode = { + direction: "row", + first: { + direction: "row", + first: "window-1", + second: "window-2", + splitPercentage: 50, + }, + second: "window-3", + splitPercentage: 50, + }; + const result = calculateSmartDirection(layout); + expect(result).toBe("column"); + }); + + it("should return row when more vertical splits exist", () => { + // 0 row splits, 2 column splits + const layout: MosaicNode = { + direction: "column", + first: { + direction: "column", + first: "window-1", + second: "window-2", + splitPercentage: 50, + }, + second: "window-3", + splitPercentage: 50, + }; + const result = calculateSmartDirection(layout); + expect(result).toBe("row"); + }); + + it("should favor column when significantly more horizontal splits", () => { + // 5 row splits, 1 column split + const layout: MosaicNode = { + direction: "row", + first: { + direction: "row", + first: { + direction: "row", + first: { + direction: "row", + first: { + direction: "row", + first: "w1", + second: "w2", + splitPercentage: 50, + }, + second: "w3", + splitPercentage: 50, + }, + second: "w4", + splitPercentage: 50, + }, + second: { + direction: "column", + first: "w5", + second: "w6", + splitPercentage: 50, + }, + splitPercentage: 50, + }, + second: "w7", + splitPercentage: 50, + }; + const result = calculateSmartDirection(layout); + expect(result).toBe("column"); + }); + }); +}); + +describe("insertWindow", () => { + describe("first window insertion", () => { + it("should return window ID for null layout", () => { + const config: LayoutConfig = { + insertionMode: "smart", + splitPercentage: 50, + insertionPosition: "second", + }; + const result = insertWindow(null, "new-window", config); + expect(result).toBe("new-window"); + }); + }); + + describe("row mode insertion", () => { + it("should create horizontal split in row mode", () => { + const config: LayoutConfig = { + insertionMode: "row", + splitPercentage: 50, + insertionPosition: "second", + }; + const result = insertWindow("window-1", "window-2", config); + expect(result).toEqual({ + direction: "row", + first: "window-1", + second: "window-2", + splitPercentage: 50, + }); + }); + + it("should respect custom split percentage in row mode", () => { + const config: LayoutConfig = { + insertionMode: "row", + splitPercentage: 70, + insertionPosition: "second", + }; + const result = insertWindow("window-1", "window-2", config); + expect(result).toEqual({ + direction: "row", + first: "window-1", + second: "window-2", + splitPercentage: 70, + }); + }); + + it("should place new window on left when position is first", () => { + const config: LayoutConfig = { + insertionMode: "row", + splitPercentage: 50, + insertionPosition: "first", + }; + const result = insertWindow("window-1", "window-2", config); + expect(result).toEqual({ + direction: "row", + first: "window-2", // New window on left + second: "window-1", + splitPercentage: 50, + }); + }); + }); + + describe("column mode insertion", () => { + it("should create vertical split in column mode", () => { + const config: LayoutConfig = { + insertionMode: "column", + splitPercentage: 50, + insertionPosition: "second", + }; + const result = insertWindow("window-1", "window-2", config); + expect(result).toEqual({ + direction: "column", + first: "window-1", + second: "window-2", + splitPercentage: 50, + }); + }); + + it("should respect custom split percentage in column mode", () => { + const config: LayoutConfig = { + insertionMode: "column", + splitPercentage: 30, + insertionPosition: "second", + }; + const result = insertWindow("window-1", "window-2", config); + expect(result).toEqual({ + direction: "column", + first: "window-1", + second: "window-2", + splitPercentage: 30, + }); + }); + + it("should place new window on top when position is first", () => { + const config: LayoutConfig = { + insertionMode: "column", + splitPercentage: 50, + insertionPosition: "first", + }; + const result = insertWindow("window-1", "window-2", config); + expect(result).toEqual({ + direction: "column", + first: "window-2", // New window on top + second: "window-1", + splitPercentage: 50, + }); + }); + }); + + describe("smart mode insertion", () => { + it("should use smart direction for single window", () => { + const config: LayoutConfig = { + insertionMode: "smart", + splitPercentage: 50, + insertionPosition: "second", + }; + const result = insertWindow("window-1", "window-2", config); + expect(result).toEqual({ + direction: "row", // Smart defaults to row for first split + first: "window-1", + second: "window-2", + splitPercentage: 50, + }); + }); + + it("should balance horizontal splits by adding vertical", () => { + // Existing layout: 2 horizontal splits + const existingLayout: MosaicNode = { + direction: "row", + first: { + direction: "row", + first: "window-1", + second: "window-2", + splitPercentage: 50, + }, + second: "window-3", + splitPercentage: 50, + }; + const config: LayoutConfig = { + insertionMode: "smart", + splitPercentage: 50, + insertionPosition: "second", + }; + const result = insertWindow(existingLayout, "window-4", config); + expect(result).toHaveProperty("direction", "column"); + expect(result).toHaveProperty("second", "window-4"); + }); + + it("should balance vertical splits by adding horizontal", () => { + // Existing layout: 2 vertical splits + const existingLayout: MosaicNode = { + direction: "column", + first: { + direction: "column", + first: "window-1", + second: "window-2", + splitPercentage: 50, + }, + second: "window-3", + splitPercentage: 50, + }; + const config: LayoutConfig = { + insertionMode: "smart", + splitPercentage: 50, + insertionPosition: "second", + }; + const result = insertWindow(existingLayout, "window-4", config); + expect(result).toHaveProperty("direction", "row"); + expect(result).toHaveProperty("second", "window-4"); + }); + }); + + describe("complex layout insertion", () => { + it("should handle insertion into complex nested layout", () => { + // Quad layout (1 row, 2 column splits) + const quadLayout: MosaicNode = { + direction: "row", + first: { + direction: "column", + first: "window-1", + second: "window-2", + splitPercentage: 50, + }, + second: { + direction: "column", + first: "window-3", + second: "window-4", + splitPercentage: 50, + }, + splitPercentage: 50, + }; + const config: LayoutConfig = { + insertionMode: "smart", + splitPercentage: 50, + insertionPosition: "second", + }; + const result = insertWindow(quadLayout, "window-5", config); + // More column splits than row, should add row + expect(result).toHaveProperty("direction", "row"); + expect(result).toHaveProperty("first", quadLayout); + expect(result).toHaveProperty("second", "window-5"); + }); + }); + + describe("split percentage edge cases", () => { + it("should handle minimum split percentage", () => { + const config: LayoutConfig = { + insertionMode: "row", + splitPercentage: 10, + insertionPosition: "second", + }; + const result = insertWindow("window-1", "window-2", config); + expect(result).toHaveProperty("splitPercentage", 10); + }); + + it("should handle maximum split percentage", () => { + const config: LayoutConfig = { + insertionMode: "row", + splitPercentage: 90, + insertionPosition: "second", + }; + const result = insertWindow("window-1", "window-2", config); + expect(result).toHaveProperty("splitPercentage", 90); + }); + }); +}); diff --git a/src/lib/layout-utils.ts b/src/lib/layout-utils.ts new file mode 100644 index 0000000..5d9de07 --- /dev/null +++ b/src/lib/layout-utils.ts @@ -0,0 +1,118 @@ +import type { MosaicNode } from "react-mosaic-component"; +import type { LayoutConfig } from "@/types/app"; + +/** + * Statistics about the layout tree structure + */ +export interface LayoutStats { + /** Number of horizontal splits in the tree */ + rowSplits: number; + /** Number of vertical splits in the tree */ + columnSplits: number; + /** Maximum depth of the tree */ + depth: number; + /** Total number of windows (leaf nodes) */ + windowCount: number; +} + +/** + * Analyzes the layout tree and returns statistics + * Used by smart direction algorithm to balance splits + */ +export function analyzeLayoutStats( + node: MosaicNode | null +): LayoutStats { + if (node === null) { + return { rowSplits: 0, columnSplits: 0, depth: 0, windowCount: 0 }; + } + + if (typeof node === "string") { + // Leaf node (window ID) + return { rowSplits: 0, columnSplits: 0, depth: 0, windowCount: 1 }; + } + + // Branch node - recursively analyze children + const firstStats = analyzeLayoutStats(node.first); + const secondStats = analyzeLayoutStats(node.second); + + return { + rowSplits: + firstStats.rowSplits + + secondStats.rowSplits + + (node.direction === "row" ? 1 : 0), + columnSplits: + firstStats.columnSplits + + secondStats.columnSplits + + (node.direction === "column" ? 1 : 0), + depth: Math.max(firstStats.depth, secondStats.depth) + 1, + windowCount: firstStats.windowCount + secondStats.windowCount, + }; +} + +/** + * Calculates the optimal split direction to balance the layout tree + * Returns 'column' if there are more horizontal splits (to balance) + * Returns 'row' if there are more vertical splits or equal (default to horizontal) + */ +export function calculateSmartDirection( + layout: MosaicNode | null +): "row" | "column" { + if (layout === null) { + return "row"; // Default to horizontal for first split + } + + const stats = analyzeLayoutStats(layout); + + // If more horizontal splits, add vertical to balance + if (stats.rowSplits > stats.columnSplits) { + return "column"; + } + + // Otherwise, default to horizontal (including when equal) + return "row"; +} + +/** + * Inserts a new window into the layout tree according to the layout configuration + * + * @param currentLayout - The current layout tree (null if no windows yet) + * @param newWindowId - The ID of the new window to insert + * @param config - Layout configuration specifying how to insert the window + * @returns The new layout tree with the window inserted + */ +export function insertWindow( + currentLayout: MosaicNode | null, + newWindowId: string, + config: LayoutConfig +): MosaicNode { + // First window - just return the window ID as leaf node + if (currentLayout === null) { + return newWindowId; + } + + // Determine split direction based on insertion mode + let direction: "row" | "column"; + + if (config.insertionMode === "row") { + direction = "row"; + } else if (config.insertionMode === "column") { + direction = "column"; + } else { + // smart mode - calculate balanced direction + direction = calculateSmartDirection(currentLayout); + } + + // Determine which side gets the new window + const [firstNode, secondNode] = + config.insertionPosition === "first" + ? [newWindowId, currentLayout] // New window on left/top + : [currentLayout, newWindowId]; // New window on right/bottom (default) + + // Create split node with new window + return { + direction, + first: firstNode, + second: secondNode, + splitPercentage: config.splitPercentage, + }; +} diff --git a/src/lib/migrations.test.ts b/src/lib/migrations.test.ts index 9e3b48b..b3fd5d1 100644 --- a/src/lib/migrations.test.ts +++ b/src/lib/migrations.test.ts @@ -2,8 +2,8 @@ import { describe, it, expect } from "vitest"; import { migrateState, validateState, CURRENT_VERSION } from "./migrations"; describe("migrations", () => { - describe("v6 to v7 migration", () => { - it("should convert numeric labels to number field", () => { + describe("v6 to v8 migration (v6→v7→v8)", () => { + it("should convert numeric labels to number field and add layoutConfig", () => { const oldState = { __version: 6, windows: {}, @@ -26,14 +26,31 @@ describe("migrations", () => { const migrated = migrateState(oldState); + // Should migrate to v8 expect(migrated.__version).toBe(CURRENT_VERSION); + + // v6→v7: numeric labels converted to number expect(migrated.workspaces.ws1.number).toBe(1); expect(migrated.workspaces.ws1.label).toBeUndefined(); expect(migrated.workspaces.ws2.number).toBe(2); expect(migrated.workspaces.ws2.label).toBeUndefined(); + + // v7→v8: layoutConfig added + 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, + }); }); - it("should convert non-numeric labels to number with label", () => { + it("should convert non-numeric labels to number with label and add layoutConfig", () => { const oldState = { __version: 6, windows: {}, @@ -57,13 +74,19 @@ describe("migrations", () => { const migrated = migrateState(oldState); expect(migrated.__version).toBe(CURRENT_VERSION); + + // v6→v7: non-numeric labels preserved expect(migrated.workspaces.ws1.number).toBe(1); expect(migrated.workspaces.ws1.label).toBe("Main"); expect(migrated.workspaces.ws2.number).toBe(2); expect(migrated.workspaces.ws2.label).toBe("Development"); + + // v7→v8: layoutConfig added + expect(migrated.workspaces.ws1.layoutConfig).toBeDefined(); + expect(migrated.workspaces.ws2.layoutConfig).toBeDefined(); }); - it("should handle mixed numeric and text labels", () => { + it("should handle mixed numeric and text labels and add layoutConfig", () => { const oldState = { __version: 6, windows: {}, @@ -93,12 +116,19 @@ describe("migrations", () => { const migrated = migrateState(oldState); expect(migrated.__version).toBe(CURRENT_VERSION); + + // v6→v7: mixed labels handled correctly expect(migrated.workspaces.ws1.number).toBe(1); expect(migrated.workspaces.ws1.label).toBeUndefined(); expect(migrated.workspaces.ws2.number).toBe(2); expect(migrated.workspaces.ws2.label).toBe("Main"); expect(migrated.workspaces.ws3.number).toBe(3); expect(migrated.workspaces.ws3.label).toBeUndefined(); + + // v7→v8: layoutConfig added to all workspaces + expect(migrated.workspaces.ws1.layoutConfig).toBeDefined(); + expect(migrated.workspaces.ws2.layoutConfig).toBeDefined(); + expect(migrated.workspaces.ws3.layoutConfig).toBeDefined(); }); it("should validate migrated state", () => { @@ -121,6 +151,79 @@ describe("migrations", () => { }); }); + describe("v7 to v8 migration", () => { + it("should add layoutConfig to existing workspaces", () => { + const v7State = { + __version: 7, + windows: { + w1: { id: "w1", appId: "profile", props: {} }, + w2: { id: "w2", appId: "nip", props: {} }, + }, + activeWorkspaceId: "ws1", + workspaces: { + ws1: { + id: "ws1", + number: 1, + label: undefined, + layout: null, + windowIds: [], + }, + ws2: { + id: "ws2", + number: 2, + label: "Development", + layout: { direction: "row", first: "w1", second: "w2", splitPercentage: 50 }, + windowIds: ["w1", "w2"], + }, + }, + }; + + const migrated = migrateState(v7State); + + expect(migrated.__version).toBe(8); + 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, + }); + + // Existing fields should be preserved + expect(migrated.workspaces.ws2.label).toBe("Development"); + expect(migrated.workspaces.ws2.layout).toEqual({ + direction: "row", + first: "w1", + second: "w2", + splitPercentage: 50, + }); + }); + + it("should validate v7→v8 migrated state", () => { + const v7State = { + __version: 7, + windows: { w1: { id: "w1", appId: "profile", props: {} } }, + activeWorkspaceId: "ws1", + workspaces: { + ws1: { + id: "ws1", + number: 1, + layout: "w1", + windowIds: ["w1"], + }, + }, + }; + + const migrated = migrateState(v7State); + expect(validateState(migrated)).toBe(true); + }); + }); + describe("validateState", () => { it("should validate correct state structure", () => { const state = { diff --git a/src/lib/migrations.ts b/src/lib/migrations.ts index 207a59d..23b00ca 100644 --- a/src/lib/migrations.ts +++ b/src/lib/migrations.ts @@ -8,7 +8,7 @@ import { GrimoireState } from "@/types/app"; import { toast } from "sonner"; -export const CURRENT_VERSION = 7; +export const CURRENT_VERSION = 8; /** * Migration function type @@ -67,6 +67,29 @@ const migrations: Record = { workspaces: migratedWorkspaces, }; }, + // Migration from v7 to v8 - adds layoutConfig to workspaces + 7: (state: any) => { + const migratedWorkspaces: Record = {}; + + // Add default layoutConfig to each workspace + for (const [id, workspace] of Object.entries(state.workspaces || {})) { + migratedWorkspaces[id] = { + ...workspace, + layoutConfig: { + insertionMode: "smart", // New smart default (auto-balance) + splitPercentage: 50, // Matches old 50/50 behavior + insertionPosition: "second", // Matches old right/bottom behavior + autoPreset: undefined, // No preset by default + }, + }; + } + + return { + ...state, + __version: 8, + workspaces: migratedWorkspaces, + }; + }, }; /** diff --git a/src/types/app.ts b/src/types/app.ts index 525cb7b..1dd4f7e 100644 --- a/src/types/app.ts +++ b/src/types/app.ts @@ -26,12 +26,45 @@ export interface WindowInstance { commandString?: string; // Original command that created this window (e.g., "profile alice@domain.com") } +/** + * Configuration for how new windows are inserted into the workspace layout tree + */ +export interface LayoutConfig { + /** + * How to determine split direction for new windows + * - 'smart': Auto-balance horizontal/vertical splits (recommended) + * - 'row': Always horizontal splits (side-by-side) + * - 'column': Always vertical splits (stacked) + */ + insertionMode: "smart" | "row" | "column"; + + /** + * Split percentage for new windows (10-90) + * Example: 70 means existing content gets 70%, new window gets 30% + */ + splitPercentage: number; + + /** + * Where to place the new window + * - 'first': Left (for row) or Top (for column) + * - 'second': Right (for row) or Bottom (for column) + */ + insertionPosition: "first" | "second"; + + /** + * Optional: Auto-maintain a preset layout structure + * When set, system tries to preserve this preset when adding windows + */ + autoPreset?: string; +} + export interface Workspace { id: string; number: number; // Numeric identifier for shortcuts (e.g., Cmd+1, Cmd+2) label?: string; // Optional user-editable label layout: MosaicNode | null; windowIds: string[]; + layoutConfig: LayoutConfig; // How new windows are inserted into layout } export interface RelayInfo {