mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-06-06 10:41:21 +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
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user