mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-11 07:56:50 +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)
266 lines
7.5 KiB
TypeScript
266 lines
7.5 KiB
TypeScript
import { useEffect, useCallback } from "react";
|
|
import { useAtom } from "jotai";
|
|
import { atomWithStorage, createJSONStorage } from "jotai/utils";
|
|
import { GrimoireState, AppId, WindowInstance } from "@/types/app";
|
|
import { useLocale } from "@/hooks/useLocale";
|
|
import * as Logic from "./logic";
|
|
import { CURRENT_VERSION, validateState, migrateState } from "@/lib/migrations";
|
|
import { toast } from "sonner";
|
|
|
|
// Initial State Definition - Empty canvas on first load
|
|
const initialState: GrimoireState = {
|
|
__version: CURRENT_VERSION,
|
|
windows: {},
|
|
activeWorkspaceId: "default",
|
|
workspaces: {
|
|
default: {
|
|
id: "default",
|
|
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
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
// Custom storage with error handling and migrations
|
|
const storage = createJSONStorage<GrimoireState>(() => ({
|
|
getItem: (key: string) => {
|
|
try {
|
|
const value = localStorage.getItem(key);
|
|
if (!value) return null;
|
|
|
|
// Parse and validate/migrate state
|
|
const parsed = JSON.parse(value);
|
|
const storedVersion = parsed.__version || 5;
|
|
|
|
// Check if migration is needed
|
|
if (storedVersion < CURRENT_VERSION) {
|
|
console.log(
|
|
`[Storage] State version outdated (v${storedVersion}), migrating...`,
|
|
);
|
|
const migrated = migrateState(parsed);
|
|
|
|
// Save migrated state back to localStorage
|
|
localStorage.setItem(key, JSON.stringify(migrated));
|
|
|
|
toast.success("State Updated", {
|
|
description: `Migrated from v${storedVersion} to v${CURRENT_VERSION}`,
|
|
duration: 3000,
|
|
});
|
|
|
|
return JSON.stringify(migrated);
|
|
}
|
|
|
|
// Validate current version state
|
|
if (!validateState(parsed)) {
|
|
console.warn(
|
|
"[Storage] State validation failed, resetting to initial state",
|
|
);
|
|
toast.error("State Corrupted", {
|
|
description: "Your state was corrupted and has been reset.",
|
|
duration: 5000,
|
|
});
|
|
return null; // Return null to use initialState
|
|
}
|
|
|
|
return value;
|
|
} catch (error) {
|
|
console.error("[Storage] Failed to read from localStorage:", error);
|
|
toast.error("Failed to Load State", {
|
|
description: "Using default state.",
|
|
duration: 5000,
|
|
});
|
|
return null;
|
|
}
|
|
},
|
|
setItem: (key: string, value: string) => {
|
|
try {
|
|
localStorage.setItem(key, value);
|
|
} catch (error) {
|
|
console.error("Failed to write to localStorage:", error);
|
|
// Handle quota exceeded or other errors
|
|
if (
|
|
error instanceof DOMException &&
|
|
error.name === "QuotaExceededError"
|
|
) {
|
|
console.error(
|
|
"localStorage quota exceeded. State will not be persisted.",
|
|
);
|
|
toast.error("Storage Full", {
|
|
description: "Could not save state. Storage quota exceeded.",
|
|
duration: 5000,
|
|
});
|
|
}
|
|
}
|
|
},
|
|
removeItem: (key: string) => {
|
|
try {
|
|
localStorage.removeItem(key);
|
|
} catch (error) {
|
|
console.warn("Failed to remove from localStorage:", error);
|
|
}
|
|
},
|
|
}));
|
|
|
|
// Persistence Atom with custom storage
|
|
export const grimoireStateAtom = atomWithStorage<GrimoireState>(
|
|
"grimoire_v6",
|
|
initialState,
|
|
storage,
|
|
);
|
|
|
|
// The Hook
|
|
export const useGrimoire = () => {
|
|
const [state, setState] = useAtom(grimoireStateAtom);
|
|
const browserLocale = useLocale();
|
|
|
|
// Initialize locale from browser if not set (moved to useEffect to avoid race condition)
|
|
useEffect(() => {
|
|
if (!state.locale) {
|
|
setState((prev) => ({ ...prev, locale: browserLocale }));
|
|
}
|
|
}, [state.locale, browserLocale, setState]);
|
|
|
|
// Wrap all callbacks in useCallback for stable references
|
|
const createWorkspace = useCallback(() => {
|
|
setState((prev) => {
|
|
const nextNumber = Logic.findLowestAvailableWorkspaceNumber(
|
|
prev.workspaces,
|
|
);
|
|
return Logic.createWorkspace(prev, nextNumber);
|
|
});
|
|
}, [setState]);
|
|
|
|
const addWindow = useCallback(
|
|
(appId: AppId, props: any, commandString?: string, customTitle?: string) =>
|
|
setState((prev) =>
|
|
Logic.addWindow(prev, {
|
|
appId,
|
|
props,
|
|
commandString,
|
|
customTitle,
|
|
}),
|
|
),
|
|
[setState],
|
|
);
|
|
|
|
const updateWindow = useCallback(
|
|
(
|
|
windowId: string,
|
|
updates: Partial<
|
|
Pick<
|
|
WindowInstance,
|
|
"props" | "title" | "customTitle" | "commandString" | "appId"
|
|
>
|
|
>,
|
|
) => setState((prev) => Logic.updateWindow(prev, windowId, updates)),
|
|
[setState],
|
|
);
|
|
|
|
const removeWindow = useCallback(
|
|
(id: string) => setState((prev) => Logic.removeWindow(prev, id)),
|
|
[setState],
|
|
);
|
|
|
|
const moveWindowToWorkspace = useCallback(
|
|
(windowId: string, targetWorkspaceId: string) =>
|
|
setState((prev) =>
|
|
Logic.moveWindowToWorkspace(prev, windowId, targetWorkspaceId),
|
|
),
|
|
[setState],
|
|
);
|
|
|
|
const updateLayout = useCallback(
|
|
(layout: any) => setState((prev) => Logic.updateLayout(prev, layout)),
|
|
[setState],
|
|
);
|
|
|
|
const setActiveWorkspace = useCallback(
|
|
(id: string) =>
|
|
setState((prev) => {
|
|
// Validate target workspace exists
|
|
if (!prev.workspaces[id]) {
|
|
console.warn(`Cannot switch to non-existent workspace: ${id}`);
|
|
return prev;
|
|
}
|
|
|
|
// If not actually switching, return unchanged
|
|
if (prev.activeWorkspaceId === id) {
|
|
return prev;
|
|
}
|
|
|
|
// Check if we're leaving an empty workspace and should auto-remove it
|
|
const currentWorkspace = prev.workspaces[prev.activeWorkspaceId];
|
|
const shouldDeleteCurrent =
|
|
currentWorkspace &&
|
|
currentWorkspace.windowIds.length === 0 &&
|
|
Object.keys(prev.workspaces).length > 1;
|
|
|
|
if (shouldDeleteCurrent) {
|
|
// Delete the empty workspace, then switch to target
|
|
const afterDelete = Logic.deleteWorkspace(
|
|
prev,
|
|
prev.activeWorkspaceId,
|
|
);
|
|
return { ...afterDelete, activeWorkspaceId: id };
|
|
}
|
|
|
|
// Normal workspace switch
|
|
return { ...prev, activeWorkspaceId: id };
|
|
}),
|
|
[setState],
|
|
);
|
|
|
|
const setActiveAccount = useCallback(
|
|
(pubkey: string | undefined) =>
|
|
setState((prev) => Logic.setActiveAccount(prev, pubkey)),
|
|
[setState],
|
|
);
|
|
|
|
const setActiveAccountRelays = useCallback(
|
|
(relays: any) =>
|
|
setState((prev) => Logic.setActiveAccountRelays(prev, relays)),
|
|
[setState],
|
|
);
|
|
|
|
const updateWorkspaceLayoutConfig = useCallback(
|
|
(
|
|
workspaceId: string,
|
|
layoutConfig: Partial<GrimoireState["workspaces"][string]["layoutConfig"]>,
|
|
) =>
|
|
setState((prev) =>
|
|
Logic.updateWorkspaceLayoutConfig(prev, workspaceId, layoutConfig),
|
|
),
|
|
[setState],
|
|
);
|
|
|
|
const applyPresetLayout = useCallback(
|
|
(preset: any) =>
|
|
setState((prev) => Logic.applyPresetLayout(prev, preset)),
|
|
[setState],
|
|
);
|
|
|
|
return {
|
|
state,
|
|
locale: state.locale || browserLocale,
|
|
activeWorkspace: state.workspaces[state.activeWorkspaceId],
|
|
createWorkspace,
|
|
addWindow,
|
|
updateWindow,
|
|
removeWindow,
|
|
moveWindowToWorkspace,
|
|
updateLayout,
|
|
setActiveWorkspace,
|
|
setActiveAccount,
|
|
setActiveAccountRelays,
|
|
updateWorkspaceLayoutConfig,
|
|
applyPresetLayout,
|
|
};
|
|
};
|