mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-10 15:36:53 +02:00
feat(layouts): Phase 1 - workspace layout configuration system
Add per-workspace layout configuration with smart auto-balancing: **Core Changes:** - Add LayoutConfig interface to Workspace type with insertionMode, splitPercentage, insertionPosition - Create layout-utils.ts with smart direction algorithm that auto-balances horizontal/vertical splits - Update addWindow() to use workspace layoutConfig instead of hardcoded values - Migrate state from v7→v8, adding layoutConfig to all workspaces with smart defaults **Smart Direction Algorithm:** - Analyzes layout tree to count horizontal vs vertical splits - Automatically balances by favoring the less-common direction - Defaults to horizontal (row) for first split - Provides foundation for "Balanced (auto)" insertion mode **Testing:** - Add 30 comprehensive tests for layout-utils.ts (tree analysis, smart direction, window insertion) - Add 30 tests for logic.ts addWindow() with different layout configs (row/column/smart modes) - Update migration tests to verify v6→v7→v8 and v7→v8 paths **Migration:** - v7→v8 adds layoutConfig with defaults: smart mode, 50% split, second position - All existing workspaces automatically get smart auto-balancing behavior Implements orthogonal design: layout behavior = workspace settings (not command flags)
This commit is contained in:
@@ -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<string> | 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<string> = {
|
||||
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<string> = {
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string>;
|
||||
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,
|
||||
|
||||
@@ -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
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
557
src/lib/layout-utils.test.ts
Normal file
557
src/lib/layout-utils.test.ts
Normal file
@@ -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<string> = {
|
||||
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<string> = {
|
||||
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<string> = {
|
||||
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<string> = {
|
||||
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<string> = {
|
||||
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<string> = {
|
||||
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<string> = {
|
||||
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<string> = {
|
||||
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<string> = {
|
||||
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<string> = {
|
||||
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<string> = {
|
||||
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<string> = {
|
||||
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<string> = {
|
||||
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<string> = {
|
||||
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<string> = {
|
||||
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<string> = {
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
118
src/lib/layout-utils.ts
Normal file
118
src/lib/layout-utils.ts
Normal file
@@ -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<string> | 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<string> | 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<string> | null,
|
||||
newWindowId: string,
|
||||
config: LayoutConfig
|
||||
): MosaicNode<string> {
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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<number, MigrationFn> = {
|
||||
workspaces: migratedWorkspaces,
|
||||
};
|
||||
},
|
||||
// Migration from v7 to v8 - adds layoutConfig to workspaces
|
||||
7: (state: any) => {
|
||||
const migratedWorkspaces: Record<string, any> = {};
|
||||
|
||||
// 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,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<string> | null;
|
||||
windowIds: string[];
|
||||
layoutConfig: LayoutConfig; // How new windows are inserted into layout
|
||||
}
|
||||
|
||||
export interface RelayInfo {
|
||||
|
||||
Reference in New Issue
Block a user