import { v4 as uuidv4 } from "uuid"; import type { MosaicNode } from "react-mosaic-component"; import { GrimoireState, WindowInstance, RelayInfo, LayoutConfig, } 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, ): 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 global layout configuration const newLayout = insertWindow(ws.layout, newWindowId, state.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 | null, windowId: string, ): MosaicNode | 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; 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 | 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: RelayInfo[], ): 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 global layout configuration. * Controls how new windows are inserted across all workspaces. */ export const updateLayoutConfig = ( state: GrimoireState, layoutConfig: Partial, ): GrimoireState => { return { ...state, layoutConfig: { ...state.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; } }; /** * Updates the label of an existing workspace. * Labels are user-friendly names that appear alongside workspace numbers. */ export const updateWorkspaceLabel = ( state: GrimoireState, workspaceId: string, label: string | undefined, ): GrimoireState => { const workspace = state.workspaces[workspaceId]; if (!workspace) { return state; // Workspace doesn't exist, return unchanged } // Normalize label: trim and treat empty strings as undefined const normalizedLabel = label?.trim() || undefined; // If label hasn't changed, return state unchanged (optimization) if (workspace.label === normalizedLabel) { return state; } return { ...state, workspaces: { ...state.workspaces, [workspaceId]: { ...workspace, label: normalizedLabel, }, }, }; }; /** * Reorders workspaces based on a list of workspace IDs. * Reassigns workspace numbers starting from 1 based on the provided order. */ export const reorderWorkspaces = ( state: GrimoireState, orderedIds: string[], ): GrimoireState => { const currentWorkspaces = Object.values(state.workspaces); const orderedSet = new Set(orderedIds); // Find any workspaces not included in the ordered list (should generally be empty if all are passed) const remainingWorkspaces = currentWorkspaces .filter((ws) => !orderedSet.has(ws.id)) .sort((a, b) => a.number - b.number); const newWorkspaces = { ...state.workspaces }; let counter = 1; // Assign new numbers to ordered IDs for (const id of orderedIds) { if (newWorkspaces[id]) { newWorkspaces[id] = { ...newWorkspaces[id], number: counter++, }; } } // Assign new numbers to remaining IDs for (const ws of remainingWorkspaces) { newWorkspaces[ws.id] = { ...newWorkspaces[ws.id], number: counter++, }; } return { ...state, workspaces: newWorkspaces, }; }; /** * Updates the list of event kinds that should be displayed in compact mode. */ export const setCompactModeKinds = ( state: GrimoireState, kinds: number[], ): GrimoireState => { return { ...state, compactModeKinds: kinds, }; };