-
-
Notifications
You must be signed in to change notification settings - Fork 13
Expand file tree
/
Copy pathlayoutStore.ts
More file actions
323 lines (287 loc) · 11.3 KB
/
Copy pathlayoutStore.ts
File metadata and controls
323 lines (287 loc) · 11.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
/**
* Persisted store for saved layout presets.
*
* A "layout" captures which panels are open, their sizes, and dock positions
* so users can save and restore their preferred panel arrangements. The data
* is persisted to localStorage alongside other `okcode:` prefixed settings.
*
* Follows the same manual-persistence pattern used by `previewStateStore.ts`
* and `simulationViewerStore.ts`.
*/
import { create } from "zustand";
// ─── Types ──────────────────────────────────────────────────────────
export type LayoutPanel = "none" | "code-viewer" | "diff-viewer" | "preview" | "simulation";
export type LayoutPreviewDock = "left" | "right" | "top" | "bottom";
export interface LayoutSidebarWidths {
/** Thread sidebar (left). Null = keep the current/default width. */
threadSidebar: number | null;
/** Code viewer sidebar (right). Null = keep the current/default width. */
codeViewer: number | null;
/** Diff viewer sidebar (right). Null = keep the current/default width. */
diffViewer: number | null;
/** Simulation sidebar (right). Null = keep the current/default width. */
simulation: number | null;
}
export interface SavedLayout {
id: string;
name: string;
createdAt: number;
updatedAt: number;
/** Which right-side panel is active, or "none" for chat-only. */
activePanel: LayoutPanel;
/** Whether the terminal drawer should be open. */
terminalOpen: boolean;
/** Terminal drawer height in px. Null = keep the current height. */
terminalHeight: number | null;
/** Sidebar widths in px. Null entries = keep the current/default width. */
sidebarWidths: LayoutSidebarWidths;
/** Preview dock position. Null = keep the current position. */
previewDock: LayoutPreviewDock | null;
/** Preview panel size in px. Null = keep the current size. */
previewSize: number | null;
}
// ─── Constants ──────────────────────────────────────────────────────
const LAYOUT_STORAGE_KEY = "okcode:saved-layouts:v1";
const MAX_SAVED_LAYOUTS = 32;
const VALID_PANELS = new Set<string>([
"none",
"code-viewer",
"diff-viewer",
"preview",
"simulation",
]);
const VALID_DOCKS = new Set<string>(["left", "right", "top", "bottom"]);
// ─── Validation helpers ─────────────────────────────────────────────
function isValidPanel(value: unknown): value is LayoutPanel {
return typeof value === "string" && VALID_PANELS.has(value);
}
function isValidDock(value: unknown): value is LayoutPreviewDock {
return typeof value === "string" && VALID_DOCKS.has(value);
}
function isFinitePositive(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value) && value > 0;
}
function isFinitePositiveOrNull(value: unknown): value is number | null {
return value === null || isFinitePositive(value);
}
function normalizeSidebarWidths(raw: unknown): LayoutSidebarWidths {
const defaults: LayoutSidebarWidths = {
threadSidebar: null,
codeViewer: null,
diffViewer: null,
simulation: null,
};
if (!raw || typeof raw !== "object") return defaults;
const obj = raw as Record<string, unknown>;
return {
threadSidebar: isFinitePositiveOrNull(obj.threadSidebar) ? obj.threadSidebar : null,
codeViewer: isFinitePositiveOrNull(obj.codeViewer) ? obj.codeViewer : null,
diffViewer: isFinitePositiveOrNull(obj.diffViewer) ? obj.diffViewer : null,
simulation: isFinitePositiveOrNull(obj.simulation) ? obj.simulation : null,
};
}
function normalizeLayout(raw: unknown): SavedLayout | null {
if (!raw || typeof raw !== "object") return null;
const obj = raw as Record<string, unknown>;
if (typeof obj.id !== "string" || obj.id.trim().length === 0) return null;
if (typeof obj.name !== "string" || obj.name.trim().length === 0) return null;
const now = Date.now();
return {
id: obj.id.trim(),
name: obj.name.trim().slice(0, 128),
createdAt:
typeof obj.createdAt === "number" && Number.isFinite(obj.createdAt) ? obj.createdAt : now,
updatedAt:
typeof obj.updatedAt === "number" && Number.isFinite(obj.updatedAt) ? obj.updatedAt : now,
activePanel: isValidPanel(obj.activePanel) ? obj.activePanel : "none",
terminalOpen: typeof obj.terminalOpen === "boolean" ? obj.terminalOpen : false,
terminalHeight: isFinitePositiveOrNull(obj.terminalHeight) ? obj.terminalHeight : null,
sidebarWidths: normalizeSidebarWidths(obj.sidebarWidths),
previewDock: isValidDock(obj.previewDock) ? obj.previewDock : null,
previewSize: isFinitePositiveOrNull(obj.previewSize) ? obj.previewSize : null,
};
}
// ─── Persistence ────────────────────────────────────────────────────
interface PersistedLayoutState {
savedLayouts: SavedLayout[];
activeLayoutId: string | null;
}
function createEmptyPersistedState(): PersistedLayoutState {
return { savedLayouts: [], activeLayoutId: null };
}
function readPersistedState(): PersistedLayoutState {
if (typeof window === "undefined") return createEmptyPersistedState();
try {
const raw = window.localStorage.getItem(LAYOUT_STORAGE_KEY);
if (!raw) return createEmptyPersistedState();
const parsed = JSON.parse(raw) as Partial<PersistedLayoutState>;
const layouts: SavedLayout[] = [];
if (Array.isArray(parsed.savedLayouts)) {
for (const entry of parsed.savedLayouts) {
if (layouts.length >= MAX_SAVED_LAYOUTS) break;
const layout = normalizeLayout(entry);
if (layout) layouts.push(layout);
}
}
const activeLayoutId =
typeof parsed.activeLayoutId === "string" &&
layouts.some((l) => l.id === parsed.activeLayoutId)
? parsed.activeLayoutId
: null;
return { savedLayouts: layouts, activeLayoutId };
} catch {
return createEmptyPersistedState();
}
}
function persistState(state: PersistedLayoutState): void {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(
LAYOUT_STORAGE_KEY,
JSON.stringify({
savedLayouts: state.savedLayouts,
activeLayoutId: state.activeLayoutId,
} satisfies PersistedLayoutState),
);
} catch {
// Ignore storage errors to avoid breaking the UI.
}
}
function snapshotPersisted(state: LayoutStoreState): PersistedLayoutState {
return {
savedLayouts: state.savedLayouts,
activeLayoutId: state.activeLayoutId,
};
}
// ─── Store ──────────────────────────────────────────────────────────
interface LayoutStoreState extends PersistedLayoutState {
/** Add or overwrite a saved layout. */
saveLayout: (layout: SavedLayout) => void;
/** Partially update a saved layout by ID. Updates `updatedAt` automatically. */
updateLayout: (
id: string,
patch: Partial<
Pick<
SavedLayout,
| "name"
| "activePanel"
| "terminalOpen"
| "terminalHeight"
| "sidebarWidths"
| "previewDock"
| "previewSize"
>
>,
) => void;
/** Delete a saved layout by ID. */
deleteLayout: (id: string) => void;
/** Rename a saved layout by ID. */
renameLayout: (id: string, name: string) => void;
/** Mark a layout as the currently active layout (or null to clear). */
setActiveLayoutId: (id: string | null) => void;
/** Reorder saved layouts by providing the desired order of IDs. */
reorderLayouts: (orderedIds: string[]) => void;
}
const initialState = readPersistedState();
export const useLayoutStore = create<LayoutStoreState>((set) => ({
...initialState,
saveLayout: (layout) => {
set((state) => {
const existingIndex = state.savedLayouts.findIndex((l) => l.id === layout.id);
let nextLayouts: SavedLayout[];
if (existingIndex >= 0) {
nextLayouts = [...state.savedLayouts];
nextLayouts[existingIndex] = layout;
} else {
if (state.savedLayouts.length >= MAX_SAVED_LAYOUTS) return state;
nextLayouts = [...state.savedLayouts, layout];
}
const next: PersistedLayoutState = {
savedLayouts: nextLayouts,
activeLayoutId: layout.id,
};
persistState(next);
return next;
});
},
updateLayout: (id, patch) => {
set((state) => {
const index = state.savedLayouts.findIndex((l) => l.id === id);
if (index < 0) return state;
const existing = state.savedLayouts[index]!;
const updated: SavedLayout = {
...existing,
...patch,
id: existing.id,
createdAt: existing.createdAt,
updatedAt: Date.now(),
name: patch.name !== undefined ? patch.name.trim().slice(0, 128) : existing.name,
};
const nextLayouts = [...state.savedLayouts];
nextLayouts[index] = updated;
const next = { ...snapshotPersisted(state), savedLayouts: nextLayouts };
persistState(next);
return next;
});
},
deleteLayout: (id) => {
set((state) => {
const nextLayouts = state.savedLayouts.filter((l) => l.id !== id);
if (nextLayouts.length === state.savedLayouts.length) return state;
const nextActiveId = state.activeLayoutId === id ? null : state.activeLayoutId;
const next: PersistedLayoutState = {
savedLayouts: nextLayouts,
activeLayoutId: nextActiveId,
};
persistState(next);
return next;
});
},
renameLayout: (id, name) => {
const trimmed = name.trim().slice(0, 128);
if (trimmed.length === 0) return;
set((state) => {
const index = state.savedLayouts.findIndex((l) => l.id === id);
if (index < 0) return state;
const existing = state.savedLayouts[index]!;
if (existing.name === trimmed) return state;
const nextLayouts = [...state.savedLayouts];
nextLayouts[index] = { ...existing, name: trimmed, updatedAt: Date.now() };
const next = { ...snapshotPersisted(state), savedLayouts: nextLayouts };
persistState(next);
return next;
});
},
setActiveLayoutId: (id) => {
set((state) => {
if (state.activeLayoutId === id) return state;
if (id !== null && !state.savedLayouts.some((l) => l.id === id)) return state;
const next = { ...snapshotPersisted(state), activeLayoutId: id };
persistState(next);
return next;
});
},
reorderLayouts: (orderedIds) => {
set((state) => {
const layoutMap = new Map(state.savedLayouts.map((l) => [l.id, l]));
const reordered: SavedLayout[] = [];
const seen = new Set<string>();
for (const id of orderedIds) {
const layout = layoutMap.get(id);
if (layout && !seen.has(id)) {
reordered.push(layout);
seen.add(id);
}
}
// Append any layouts not mentioned in the order (defensive).
for (const layout of state.savedLayouts) {
if (!seen.has(layout.id)) {
reordered.push(layout);
}
}
const next = { ...snapshotPersisted(state), savedLayouts: reordered };
persistState(next);
return next;
});
},
}));