Skip to content

Commit 5bdb664

Browse files
committed
Persist saved layout preferences
- Add a persisted layout store for saving, updating, deleting, and reordering presets - Capture and restore panel, terminal, sidebar, and preview layout state
1 parent dd5bf1a commit 5bdb664

2 files changed

Lines changed: 585 additions & 0 deletions

File tree

Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
/**
2+
* Hook that coordinates across all panel/terminal stores to capture
3+
* the current layout state and apply a saved layout.
4+
*
5+
* Sidebar widths are read/written via the same localStorage keys the
6+
* `<SidebarRail>` component uses, so persisted widths survive page
7+
* reloads. Panel open/close states apply immediately via Zustand;
8+
* sidebar widths take effect on next panel toggle or page load (the
9+
* SidebarRail reads them from localStorage on mount).
10+
*/
11+
12+
import { useCallback } from "react";
13+
import { Schema } from "effect";
14+
import type { ProjectId, ThreadId } from "@okcode/contracts";
15+
import { getLocalStorageItem, setLocalStorageItem } from "./useLocalStorage";
16+
import { useCodeViewerStore } from "../codeViewerStore";
17+
import { useDiffViewerStore } from "../diffViewerStore";
18+
import { useSimulationViewerStore } from "../simulationViewerStore";
19+
import { usePreviewStateStore } from "../previewStateStore";
20+
import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";
21+
import {
22+
useLayoutStore,
23+
type LayoutPanel,
24+
type LayoutSidebarWidths,
25+
type SavedLayout,
26+
} from "../layoutStore";
27+
28+
// ─── Sidebar width localStorage keys ───────────────────────────────
29+
// These must match the storageKey values used by the <Sidebar> components
30+
// in _chat.tsx and _chat.$threadId.tsx.
31+
const SIDEBAR_WIDTH_KEYS = {
32+
threadSidebar: "chat_thread_sidebar_width",
33+
codeViewer: "chat_code_viewer_sidebar_width",
34+
diffViewer: "chat_diff_viewer_sidebar_width",
35+
simulation: "chat_simulation_sidebar_width",
36+
} as const satisfies Record<keyof LayoutSidebarWidths, string>;
37+
38+
// ─── Helpers ────────────────────────────────────────────────────────
39+
40+
function generateLayoutId(): string {
41+
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
42+
return crypto.randomUUID();
43+
}
44+
// Fallback for older environments.
45+
return `layout-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
46+
}
47+
48+
function readSidebarWidths(): LayoutSidebarWidths {
49+
return {
50+
threadSidebar: getLocalStorageItem(SIDEBAR_WIDTH_KEYS.threadSidebar, Schema.Finite),
51+
codeViewer: getLocalStorageItem(SIDEBAR_WIDTH_KEYS.codeViewer, Schema.Finite),
52+
diffViewer: getLocalStorageItem(SIDEBAR_WIDTH_KEYS.diffViewer, Schema.Finite),
53+
simulation: getLocalStorageItem(SIDEBAR_WIDTH_KEYS.simulation, Schema.Finite),
54+
};
55+
}
56+
57+
function writeSidebarWidths(widths: LayoutSidebarWidths): void {
58+
for (const [field, storageKey] of Object.entries(SIDEBAR_WIDTH_KEYS) as Array<
59+
[keyof LayoutSidebarWidths, string]
60+
>) {
61+
const value = widths[field];
62+
if (value !== null) {
63+
setLocalStorageItem(storageKey, value, Schema.Finite);
64+
}
65+
}
66+
}
67+
68+
/**
69+
* Determine which panel is currently active based on all panel store states.
70+
*/
71+
function resolveActivePanel(
72+
codeViewerOpen: boolean,
73+
diffViewerOpen: boolean,
74+
previewOpen: boolean,
75+
simulationOpen: boolean,
76+
): LayoutPanel {
77+
if (codeViewerOpen) return "code-viewer";
78+
if (diffViewerOpen) return "diff-viewer";
79+
if (previewOpen) return "preview";
80+
if (simulationOpen) return "simulation";
81+
return "none";
82+
}
83+
84+
// ─── Hook ───────────────────────────────────────────────────────────
85+
86+
export interface UseLayoutActionsResult {
87+
/**
88+
* Snapshot the current panel arrangement into a `SavedLayout` object.
89+
* Does not persist it — call `saveCurrentAsLayout` for that.
90+
*/
91+
captureCurrentLayout: (
92+
name: string,
93+
threadId: ThreadId | null,
94+
projectId: ProjectId | null,
95+
) => SavedLayout;
96+
97+
/**
98+
* Apply a saved layout by setting all panel stores to the saved state.
99+
* Sidebar widths are written to localStorage and will take effect on
100+
* next panel toggle or page load.
101+
*/
102+
applyLayout: (
103+
layout: SavedLayout,
104+
threadId: ThreadId | null,
105+
projectId: ProjectId | null,
106+
) => void;
107+
108+
/**
109+
* Capture the current layout and save it to the layout store in one step.
110+
* Returns the ID of the newly saved layout.
111+
*/
112+
saveCurrentAsLayout: (
113+
name: string,
114+
threadId: ThreadId | null,
115+
projectId: ProjectId | null,
116+
) => string;
117+
118+
/**
119+
* Overwrite a saved layout with the current panel arrangement,
120+
* preserving its name and ID.
121+
*/
122+
updateLayoutFromCurrent: (
123+
layoutId: string,
124+
threadId: ThreadId | null,
125+
projectId: ProjectId | null,
126+
) => void;
127+
}
128+
129+
export function useLayoutActions(): UseLayoutActionsResult {
130+
const saveLayoutToStore = useLayoutStore((s) => s.saveLayout);
131+
const updateLayoutInStore = useLayoutStore((s) => s.updateLayout);
132+
133+
const captureCurrentLayout = useCallback(
134+
(name: string, threadId: ThreadId | null, projectId: ProjectId | null): SavedLayout => {
135+
const codeViewerOpen = useCodeViewerStore.getState().isOpen;
136+
const diffState = useDiffViewerStore.getState();
137+
const diffViewerOpen = diffState.isOpen;
138+
const simulationOpen = useSimulationViewerStore.getState().isOpen;
139+
const previewState = usePreviewStateStore.getState();
140+
const previewOpen = projectId
141+
? (previewState.openByProjectId[projectId] ?? false)
142+
: false;
143+
144+
const terminalStoreState = useTerminalStateStore.getState();
145+
const threadTerminal = threadId
146+
? selectThreadTerminalState(terminalStoreState.terminalStateByThreadId, threadId)
147+
: null;
148+
149+
const previewDock = projectId
150+
? (previewState.dockByProjectId[projectId] ?? null)
151+
: null;
152+
const previewSize = projectId
153+
? (previewState.sizeByProjectId[projectId] ?? null)
154+
: null;
155+
156+
const now = Date.now();
157+
return {
158+
id: generateLayoutId(),
159+
name: name.trim().slice(0, 128) || "Untitled Layout",
160+
createdAt: now,
161+
updatedAt: now,
162+
activePanel: resolveActivePanel(codeViewerOpen, diffViewerOpen, previewOpen, simulationOpen),
163+
terminalOpen: threadTerminal?.terminalOpen ?? false,
164+
terminalHeight: threadTerminal?.terminalHeight ?? null,
165+
sidebarWidths: readSidebarWidths(),
166+
previewDock,
167+
previewSize,
168+
};
169+
},
170+
[],
171+
);
172+
173+
const applyLayout = useCallback(
174+
(layout: SavedLayout, threadId: ThreadId | null, projectId: ProjectId | null) => {
175+
// ── 1. Close all right-side panels first ────────────────────
176+
const codeViewerStore = useCodeViewerStore.getState();
177+
const diffViewerStore = useDiffViewerStore.getState();
178+
const simulationStore = useSimulationViewerStore.getState();
179+
const previewStore = usePreviewStateStore.getState();
180+
181+
if (codeViewerStore.isOpen) codeViewerStore.close();
182+
if (diffViewerStore.isOpen) diffViewerStore.close();
183+
if (simulationStore.isOpen) simulationStore.close();
184+
if (projectId && previewStore.openByProjectId[projectId]) {
185+
previewStore.setProjectOpen(projectId, false);
186+
}
187+
188+
// ── 2. Open the target panel ───────────────────────────────
189+
switch (layout.activePanel) {
190+
case "code-viewer":
191+
useCodeViewerStore.getState().open();
192+
break;
193+
case "diff-viewer":
194+
if (threadId) {
195+
useDiffViewerStore.getState().openConversation(threadId);
196+
}
197+
break;
198+
case "preview":
199+
if (projectId) {
200+
usePreviewStateStore.getState().setProjectOpen(projectId, true);
201+
}
202+
break;
203+
case "simulation":
204+
useSimulationViewerStore.getState().open();
205+
break;
206+
case "none":
207+
default:
208+
break;
209+
}
210+
211+
// ── 3. Apply preview dock & size ───────────────────────────
212+
if (projectId) {
213+
if (layout.previewDock !== null) {
214+
usePreviewStateStore.getState().setProjectDock(projectId, layout.previewDock);
215+
}
216+
if (layout.previewSize !== null) {
217+
usePreviewStateStore.getState().setProjectSize(projectId, layout.previewSize);
218+
}
219+
}
220+
221+
// ── 4. Apply terminal state ────────────────────────────────
222+
if (threadId) {
223+
const terminalStore = useTerminalStateStore.getState();
224+
terminalStore.setTerminalOpen(threadId, layout.terminalOpen);
225+
if (layout.terminalHeight !== null) {
226+
terminalStore.setTerminalHeight(threadId, layout.terminalHeight);
227+
}
228+
}
229+
230+
// ── 5. Apply sidebar widths to localStorage ────────────────
231+
writeSidebarWidths(layout.sidebarWidths);
232+
233+
// ── 6. Mark this layout as active ──────────────────────────
234+
useLayoutStore.getState().setActiveLayoutId(layout.id);
235+
},
236+
[],
237+
);
238+
239+
const saveCurrentAsLayout = useCallback(
240+
(name: string, threadId: ThreadId | null, projectId: ProjectId | null): string => {
241+
const layout = captureCurrentLayout(name, threadId, projectId);
242+
saveLayoutToStore(layout);
243+
return layout.id;
244+
},
245+
[captureCurrentLayout, saveLayoutToStore],
246+
);
247+
248+
const updateLayoutFromCurrent = useCallback(
249+
(layoutId: string, threadId: ThreadId | null, projectId: ProjectId | null) => {
250+
const layouts = useLayoutStore.getState().savedLayouts;
251+
const existing = layouts.find((l) => l.id === layoutId);
252+
if (!existing) return;
253+
254+
const snapshot = captureCurrentLayout(existing.name, threadId, projectId);
255+
updateLayoutInStore(layoutId, {
256+
activePanel: snapshot.activePanel,
257+
terminalOpen: snapshot.terminalOpen,
258+
terminalHeight: snapshot.terminalHeight,
259+
sidebarWidths: snapshot.sidebarWidths,
260+
previewDock: snapshot.previewDock,
261+
previewSize: snapshot.previewSize,
262+
});
263+
},
264+
[captureCurrentLayout, updateLayoutInStore],
265+
);
266+
267+
return {
268+
captureCurrentLayout,
269+
applyLayout,
270+
saveCurrentAsLayout,
271+
updateLayoutFromCurrent,
272+
};
273+
}

0 commit comments

Comments
 (0)