Files
grimoire/src/core/logic.ts
Alejandro Gómez 52f39a8073 feat: add layout presets system with /layout command
Phase 3 implementation:
- Created layout-presets.ts with 3 built-in presets (side-by-side, main-sidebar, grid)
- Implemented fillLayoutTemplate() for recursive template filling with window IDs
- Added collectWindowIds() for depth-first traversal of layout trees
- Created applyPresetToLayout() to reorganize existing windows

- Created layout-parser.ts for /layout command argument parsing
- Added layout command to man.ts with documentation and examples

- Built LayoutViewer component with:
  * Visual preset gallery with diagrams
  * Window count validation
  * Apply preset functionality
  * Error handling for insufficient windows
  * Command-line preset specification support

- Wired LayoutViewer into WindowRenderer with lazy loading
- Added "layout" to AppId type definition
- Exposed applyPresetLayout in useGrimoire hook

Presets allow users to quickly reorganize multiple windows into
common layouts: 50/50 splits, 70/30 main+sidebar, or 2×2 grids.

Generated with [Claude Code](https://claude.com/claude-code)
2025-12-18 12:13:28 +01:00

409 lines
9.2 KiB
TypeScript

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";
import { applyPresetToLayout, type LayoutPreset } from "@/lib/layout-presets";
/**
* Finds the lowest available workspace number.
* - If workspaces have numbers [1, 2, 4], returns 3
* - If workspaces have numbers [1, 2, 3], returns 4
* - If workspaces have numbers [2, 3, 4], returns 1
*/
export const findLowestAvailableWorkspaceNumber = (
workspaces: Record<string, { number: number }>,
): number => {
// Get all workspace numbers as a Set for O(1) lookup
const numbers = new Set(Object.values(workspaces).map((ws) => ws.number));
// If no workspaces exist, start at 1
if (numbers.size === 0) return 1;
// Find first gap starting from 1
let candidate = 1;
while (numbers.has(candidate)) {
candidate++;
}
return candidate;
};
/**
* Creates a new, empty workspace.
*/
export const createWorkspace = (
state: GrimoireState,
number: number,
label?: string,
): GrimoireState => {
const newId = uuidv4();
return {
...state,
activeWorkspaceId: newId,
workspaces: {
...state.workspaces,
[newId]: {
id: newId,
number,
label,
layout: null,
windowIds: [],
},
},
};
};
/**
* Adds a window to the global store and to the active workspace.
*/
export const addWindow = (
state: GrimoireState,
payload: {
appId: string;
props: any;
commandString?: string;
customTitle?: string;
},
): GrimoireState => {
const activeId = state.activeWorkspaceId;
const ws = state.workspaces[activeId];
const newWindowId = uuidv4();
const newWindow: WindowInstance = {
id: newWindowId,
appId: payload.appId as any,
customTitle: payload.customTitle,
props: payload.props,
commandString: payload.commandString,
};
// Insert window using workspace layout configuration
const newLayout = insertWindow(ws.layout, newWindowId, ws.layoutConfig);
return {
...state,
windows: {
...state.windows,
[newWindowId]: newWindow,
},
workspaces: {
...state.workspaces,
[activeId]: {
...ws,
layout: newLayout,
windowIds: [...ws.windowIds, newWindowId],
},
},
};
};
/**
* Recursively removes a window from the layout tree.
*/
const removeFromLayout = (
layout: MosaicNode<string> | null,
windowId: string,
): MosaicNode<string> | null => {
if (layout === null) {
return null;
}
if (typeof layout === "string") {
return layout === windowId ? null : layout;
}
const firstResult = removeFromLayout(layout.first, windowId);
const secondResult = removeFromLayout(layout.second, windowId);
if (firstResult === null && secondResult !== null) {
return secondResult;
}
if (secondResult === null && firstResult !== null) {
return firstResult;
}
if (firstResult === null && secondResult === null) {
return null;
}
if (firstResult === layout.first && secondResult === layout.second) {
return layout;
}
return {
...layout,
first: firstResult!,
second: secondResult!,
};
};
/**
* Removes a window from the active workspace's layout and windowIds.
* Also removes the window from the global windows object.
*/
export const removeWindow = (
state: GrimoireState,
windowId: string,
): GrimoireState => {
const activeId = state.activeWorkspaceId;
const ws = state.workspaces[activeId];
const newLayout = removeFromLayout(ws.layout, windowId);
const newWindowIds = ws.windowIds.filter((id) => id !== windowId);
// Remove from global windows object
const { [windowId]: _removedWindow, ...remainingWindows } = state.windows;
return {
...state,
windows: remainingWindows,
workspaces: {
...state.workspaces,
[activeId]: {
...ws,
layout: newLayout,
windowIds: newWindowIds,
},
},
};
};
/**
* Moves a window from current workspace to target workspace.
*/
export const moveWindowToWorkspace = (
state: GrimoireState,
windowId: string,
targetWorkspaceId: string,
): GrimoireState => {
const currentId = state.activeWorkspaceId;
const currentWs = state.workspaces[currentId];
const targetWs = state.workspaces[targetWorkspaceId];
if (!targetWs) {
return state;
}
const newCurrentLayout = removeFromLayout(currentWs.layout, windowId);
const newCurrentWindowIds = currentWs.windowIds.filter(
(id) => id !== windowId,
);
let newTargetLayout: MosaicNode<string>;
if (targetWs.layout === null) {
newTargetLayout = windowId;
} else {
newTargetLayout = {
direction: "row",
first: targetWs.layout,
second: windowId,
splitPercentage: 50,
};
}
return {
...state,
workspaces: {
...state.workspaces,
[currentId]: {
...currentWs,
layout: newCurrentLayout,
windowIds: newCurrentWindowIds,
},
[targetWorkspaceId]: {
...targetWs,
layout: newTargetLayout,
windowIds: [...targetWs.windowIds, windowId],
},
},
};
};
export const updateLayout = (
state: GrimoireState,
layout: MosaicNode<string> | null,
): GrimoireState => {
const activeId = state.activeWorkspaceId;
return {
...state,
workspaces: {
...state.workspaces,
[activeId]: {
...state.workspaces[activeId],
layout,
},
},
};
};
/**
* Sets the active account (pubkey).
*/
export const setActiveAccount = (
state: GrimoireState,
pubkey: string | undefined,
): GrimoireState => {
// If pubkey is already set to the same value, return state unchanged
if (state.activeAccount?.pubkey === pubkey) {
return state;
}
if (!pubkey) {
return {
...state,
activeAccount: undefined,
};
}
return {
...state,
activeAccount: {
pubkey,
relays: state.activeAccount?.relays,
},
};
};
/**
* Updates the relay list for the active account.
*/
export const setActiveAccountRelays = (
state: GrimoireState,
relays: UserRelays,
): GrimoireState => {
if (!state.activeAccount) {
return state;
}
// If relays reference hasn't changed, return state unchanged
if (state.activeAccount.relays === relays) {
return state;
}
return {
...state,
activeAccount: {
...state.activeAccount,
relays,
},
};
};
/**
* Deletes a workspace by ID.
* Cannot delete the last remaining workspace.
* Does NOT change activeWorkspaceId - caller is responsible for workspace navigation.
*/
export const deleteWorkspace = (
state: GrimoireState,
workspaceId: string,
): GrimoireState => {
const workspaceIds = Object.keys(state.workspaces);
// Don't delete if it's the only workspace
if (workspaceIds.length <= 1) {
return state;
}
// Don't delete if workspace doesn't exist
if (!state.workspaces[workspaceId]) {
return state;
}
// Remove the workspace (don't touch activeWorkspaceId - that's the caller's job)
const { [workspaceId]: _removed, ...remainingWorkspaces } = state.workspaces;
return {
...state,
workspaces: remainingWorkspaces,
};
};
/**
* Updates an existing window with new properties.
* Allows updating props, title, customTitle, commandString, and even appId (which changes the viewer type).
*/
export const updateWindow = (
state: GrimoireState,
windowId: string,
updates: Partial<
Pick<
WindowInstance,
"props" | "title" | "customTitle" | "commandString" | "appId"
>
>,
): GrimoireState => {
const window = state.windows[windowId];
if (!window) {
return state; // Window doesn't exist, return unchanged
}
return {
...state,
windows: {
...state.windows,
[windowId]: { ...window, ...updates },
},
};
};
/**
* Updates the layout configuration for a workspace.
* Controls how new windows are inserted into the workspace layout.
*/
export const updateWorkspaceLayoutConfig = (
state: GrimoireState,
workspaceId: string,
layoutConfig: Partial<GrimoireState["workspaces"][string]["layoutConfig"]>,
): GrimoireState => {
const workspace = state.workspaces[workspaceId];
if (!workspace) {
return state; // Workspace doesn't exist, return unchanged
}
return {
...state,
workspaces: {
...state.workspaces,
[workspaceId]: {
...workspace,
layoutConfig: {
...workspace.layoutConfig,
...layoutConfig,
},
},
},
};
};
/**
* Applies a preset layout to the active workspace.
* Reorganizes existing windows according to the preset template.
*/
export const applyPresetLayout = (
state: GrimoireState,
preset: LayoutPreset,
): GrimoireState => {
const activeId = state.activeWorkspaceId;
const ws = state.workspaces[activeId];
try {
// Apply preset to current layout
const newLayout = applyPresetToLayout(ws.layout, preset);
return {
...state,
workspaces: {
...state.workspaces,
[activeId]: {
...ws,
layout: newLayout,
},
},
};
} catch (error) {
// If preset application fails (not enough windows, etc.), return unchanged
console.error("[Layout] Failed to apply preset:", error);
return state;
}
};