mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-11 16:07:15 +02:00
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)
409 lines
9.2 KiB
TypeScript
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;
|
|
}
|
|
};
|