返回 DeepSeek-Reasonix
layout.ts
根目录 / desktop / frontend / src / store / layout.ts
1 // layout owns the desktop shell's geometry state — sidebar + right-dock widths
2 // and the sidebar collapse flag — as a selectable store rather than App-local
3 // useState. Components read a single slice via selector (only that slice
4 // re-renders), with no prop drilling. The geometry constants, clamps, and the
5 // localStorage-backed load/save helpers live here too: they are layout-domain
6 // knowledge that belongs with the store, and keeping them here lets the store
7 // initialize itself from persisted state at module load without depending on App.
8 //
9 // Persistence behavior is intentionally unchanged from the previous App-local
10 // implementation: the store's setters are pure (state only), and callers keep
11 // invoking the exported save* helpers exactly where they did before, so the
12 // on-disk localStorage schema and write timing are byte-identical.
13
14 import type { Dispatch, SetStateAction } from "react";
15 import { create } from "zustand";
16
17 import { loadLayoutSize, saveLayoutSize } from "../lib/layoutPreferences";
18
19 import { applySetState } from "./setState";
20
21 const SIDEBAR_COLLAPSED_KEY = "reasonix.sidebar.collapsed";
22 const SIDEBAR_DEFAULT_WIDTH = 264;
23 export const SIDEBAR_MIN_WIDTH = 264;
24 export const SIDEBAR_MAX_WIDTH = 300;
25 const SIDEBAR_VIEWPORT_RATIO = 0.18;
26
27 const RIGHT_DOCK_TREE_DEFAULT_WIDTH = 300;
28 export const RIGHT_DOCK_TREE_MIN_WIDTH = 300;
29 export const RIGHT_DOCK_TREE_MAX_WIDTH = 560;
30 export const RIGHT_DOCK_PREVIEW_DEFAULT_WIDTH = 660;
31 export const RIGHT_DOCK_PREVIEW_MIN_WIDTH = 420;
32 export const RIGHT_DOCK_MIN_RENDER_WIDTH = 280;
33 export const RIGHT_DOCK_MAX_WIDTH = 860;
34 const WORKSPACE_PANEL_OPEN_KEY = "reasonix.workspacePanel.open";
35 // First-launch default when no preference is stored (matches post-#6371 UX).
36 const WORKSPACE_PANEL_DEFAULT_OPEN = true;
37
38 export function clampSidebarWidth(width: number): number {
39 return Math.min(SIDEBAR_MAX_WIDTH, Math.max(SIDEBAR_MIN_WIDTH, Math.round(width)));
40 }
41
42 function clampStoredSidebarWidth(width: number): number {
43 return Math.min(SIDEBAR_MAX_WIDTH, Math.max(SIDEBAR_MIN_WIDTH, Math.round(width)));
44 }
45
46 export function clampRightDockPreviewWidth(width: number, maxWidth = RIGHT_DOCK_MAX_WIDTH): number {
47 // Cap at maxWidth (which may be below the default maximum when the viewport
48 // is narrow) while never dropping below the applicable minimum.
49 return Math.min(Math.max(maxWidth, RIGHT_DOCK_PREVIEW_MIN_WIDTH), Math.max(RIGHT_DOCK_PREVIEW_MIN_WIDTH, Math.round(width)));
50 }
51
52 export function clampRightDockTreeWidth(width: number, maxWidth = RIGHT_DOCK_TREE_MAX_WIDTH): number {
53 return Math.min(Math.max(maxWidth, RIGHT_DOCK_TREE_MIN_WIDTH), Math.max(RIGHT_DOCK_TREE_MIN_WIDTH, Math.round(width)));
54 }
55
56 function clampStoredRightDockTreeWidth(width: number): number {
57 // Stored widths are validated again against the live viewport at load time
58 // (resolveWorkspacePanelWidth clamps to the chat pane's 400px floor), so
59 // persistence only guards the sane lower bound and integer form.
60 return Math.max(RIGHT_DOCK_TREE_MIN_WIDTH, Math.round(width));
61 }
62
63 export function defaultSidebarWidth(): number {
64 if (typeof window !== "undefined") {
65 return clampSidebarWidth(window.innerWidth * SIDEBAR_VIEWPORT_RATIO);
66 }
67 return SIDEBAR_DEFAULT_WIDTH;
68 }
69
70 export function defaultRightDockTreeWidth(): number {
71 return RIGHT_DOCK_TREE_DEFAULT_WIDTH;
72 }
73
74 function loadSidebarCollapsed(): boolean {
75 if (typeof window === "undefined") return false;
76 try {
77 return window.localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === "1";
78 } catch {
79 return false;
80 }
81 }
82
83 export function saveSidebarCollapsed(collapsed: boolean): void {
84 if (typeof window === "undefined") return;
85 try {
86 window.localStorage.setItem(SIDEBAR_COLLAPSED_KEY, collapsed ? "1" : "0");
87 } catch {
88 /* ignore storage failures */
89 }
90 }
91
92 function loadSidebarWidth(): number {
93 return loadLayoutSize("sidebarWidthGraphite", defaultSidebarWidth(), clampStoredSidebarWidth);
94 }
95
96 export function saveSidebarWidth(width: number): void {
97 saveLayoutSize("sidebarWidthGraphite", width, clampStoredSidebarWidth);
98 }
99
100 function loadRightDockTreeWidth(): number {
101 return loadLayoutSize("rightDockTreeWidth", defaultRightDockTreeWidth(), clampStoredRightDockTreeWidth);
102 }
103
104 export function saveRightDockTreeWidth(width: number): void {
105 saveLayoutSize("rightDockTreeWidth", width, clampStoredRightDockTreeWidth);
106 }
107
108 function loadRightDockPreviewWidth(): number {
109 return loadLayoutSize("rightDockPreviewWidth", RIGHT_DOCK_PREVIEW_DEFAULT_WIDTH, clampRightDockPreviewWidth);
110 }
111
112 export function saveRightDockPreviewWidth(width: number): void {
113 saveLayoutSize("rightDockPreviewWidth", width, clampRightDockPreviewWidth);
114 }
115
116 // rightDockMode selects what the right dock shows. workspacePanelOpen is
117 // restored from localStorage (same pattern as sidebarCollapsed) so a collapsed
118 // dock survives restart. maximized/preview stay session-local — they are view
119 // layout, not a durable preference. Transient geometry (drag flags, live drag
120 // widths, the sidebar button-press flag) is session-local state on this store
121 // so resize lifecycles and their consumers read one source of truth; measured
122 // footer height and viewport width live in the windowChrome store.
123 export type RightDockMode = "context" | "files" | "changed" | "remote" | "browser";
124
125 // terminalPanelOpen is independent from rightDockMode — the terminal is a
126 // bottom drawer that coexists with the workspace panel, not a mode of it.
127 // Persisted to localStorage so it survives restart.
128 const TERMINAL_PANEL_OPEN_KEY = "reasonix.terminalPanel.open";
129 const TERMINAL_PANEL_DEFAULT_OPEN = false;
130
131 function loadTerminalPanelOpen(): boolean {
132 if (typeof window === "undefined") return TERMINAL_PANEL_DEFAULT_OPEN;
133 try {
134 return window.localStorage.getItem(TERMINAL_PANEL_OPEN_KEY) === "1";
135 } catch {
136 return TERMINAL_PANEL_DEFAULT_OPEN;
137 }
138 }
139
140 export function saveTerminalPanelOpen(open: boolean): void {
141 if (typeof window === "undefined") return;
142 try {
143 window.localStorage.setItem(TERMINAL_PANEL_OPEN_KEY, open ? "1" : "0");
144 } catch {
145 /* ignore storage failures */
146 }
147 }
148
149 // Terminal height defaults and clamps for the bottom drawer.
150 export const TERMINAL_DEFAULT_HEIGHT = 280;
151 export const TERMINAL_MIN_HEIGHT = 120;
152 export const TERMINAL_MAX_HEIGHT_RATIO = 0.5; // max 50% of viewport height
153
154 const TERMINAL_HEIGHT_KEY = "reasonix.terminalPanel.height";
155
156 function loadTerminalHeight(): number {
157 if (typeof window === "undefined") return TERMINAL_DEFAULT_HEIGHT;
158 try {
159 const raw = window.localStorage.getItem(TERMINAL_HEIGHT_KEY);
160 if (raw === null) return TERMINAL_DEFAULT_HEIGHT;
161 const parsed = Number(raw);
162 if (Number.isFinite(parsed) && parsed >= TERMINAL_MIN_HEIGHT) return parsed;
163 return TERMINAL_DEFAULT_HEIGHT;
164 } catch {
165 return TERMINAL_DEFAULT_HEIGHT;
166 }
167 }
168
169 export function saveTerminalHeight(height: number): void {
170 if (typeof window === "undefined") return;
171 try {
172 window.localStorage.setItem(TERMINAL_HEIGHT_KEY, String(Math.round(height)));
173 } catch {
174 /* ignore storage failures */
175 }
176 }
177
178 export function terminalMaxHeight(viewportHeight: number): number {
179 return Math.max(TERMINAL_MIN_HEIGHT, Math.floor(Math.max(0, viewportHeight) * TERMINAL_MAX_HEIGHT_RATIO));
180 }
181
182 export function clampTerminalHeight(height: number, viewportHeight: number): number {
183 const max = terminalMaxHeight(viewportHeight);
184 return Math.min(max, Math.max(TERMINAL_MIN_HEIGHT, Math.round(height)));
185 }
186
187 function workspacePanelOpenStorageKey(workspaceRoot: string): string {
188 return workspaceRoot ? `${WORKSPACE_PANEL_OPEN_KEY}.${workspaceRoot}` : WORKSPACE_PANEL_OPEN_KEY;
189 }
190
191 export function loadWorkspacePanelOpen(workspaceRoot: string): boolean {
192 if (typeof window === "undefined") return WORKSPACE_PANEL_DEFAULT_OPEN;
193 try {
194 const raw = window.localStorage.getItem(workspacePanelOpenStorageKey(workspaceRoot));
195 if (raw !== null) return raw !== "0";
196 // Migration: the legacy single global key predates per-project keys.
197 // When a project has no stored preference yet, seed it from the old
198 // global value so an upgrade does not flip a user's existing choice
199 // (e.g. they had the dock closed; first open of any project stays closed).
200 const legacyRaw = window.localStorage.getItem(WORKSPACE_PANEL_OPEN_KEY);
201 if (legacyRaw !== null) return legacyRaw !== "0";
202 return WORKSPACE_PANEL_DEFAULT_OPEN;
203 } catch {
204 return WORKSPACE_PANEL_DEFAULT_OPEN;
205 }
206 }
207
208 export function saveWorkspacePanelOpen(open: boolean, workspaceRoot = ""): void {
209 if (typeof window === "undefined") return;
210 try {
211 window.localStorage.setItem(workspacePanelOpenStorageKey(workspaceRoot), open ? "1" : "0");
212 } catch {
213 /* ignore storage failures */
214 }
215 }
216
217 export type LayoutState = {
218 sidebarCollapsed: boolean;
219 sidebarWidth: number;
220 rightDockTreeWidth: number;
221 rightDockPreviewWidth: number;
222 workspacePanelOpen: boolean;
223 workspacePanelMaximized: boolean;
224 workspacePreviewActive: boolean;
225 rightDockMode: RightDockMode;
226 terminalPanelOpen: boolean;
227 terminalHeight: number;
228 sidebarTogglePressed: boolean;
229 sidebarResizing: boolean;
230 liveSidebarWidth: number | null;
231 workspacePanelResizing: boolean;
232 liveWorkspacePanelRenderWidth: number | null;
233 liveTerminalHeight: number | null;
234 setSidebarCollapsed: (collapsed: boolean) => void;
235 setSidebarWidth: (width: number) => void;
236 setRightDockTreeWidth: (width: number) => void;
237 setRightDockPreviewWidth: (width: number) => void;
238 setWorkspacePanelOpen: Dispatch<SetStateAction<boolean>>;
239 setWorkspacePanelMaximized: Dispatch<SetStateAction<boolean>>;
240 setWorkspacePreviewActive: Dispatch<SetStateAction<boolean>>;
241 setRightDockMode: Dispatch<SetStateAction<RightDockMode>>;
242 setTerminalPanelOpen: Dispatch<SetStateAction<boolean>>;
243 setTerminalHeight: (height: number) => void;
244 setSidebarTogglePressed: (pressed: boolean) => void;
245 setSidebarResizing: (resizing: boolean) => void;
246 setLiveSidebarWidth: (width: number | null) => void;
247 setWorkspacePanelResizing: (resizing: boolean) => void;
248 setLiveWorkspacePanelRenderWidth: (width: number | null) => void;
249 setLiveTerminalHeight: (height: number | null) => void;
250 };
251
252 export const useLayoutStore = create<LayoutState>((set) => ({
253 sidebarCollapsed: loadSidebarCollapsed(),
254 sidebarWidth: loadSidebarWidth(),
255 rightDockTreeWidth: loadRightDockTreeWidth(),
256 rightDockPreviewWidth: loadRightDockPreviewWidth(),
257 workspacePanelOpen: loadWorkspacePanelOpen(""),
258 workspacePanelMaximized: false,
259 workspacePreviewActive: false,
260 rightDockMode: "context",
261 terminalPanelOpen: loadTerminalPanelOpen(),
262 terminalHeight: loadTerminalHeight(),
263 sidebarTogglePressed: false,
264 sidebarResizing: false,
265 liveSidebarWidth: null,
266 workspacePanelResizing: false,
267 liveWorkspacePanelRenderWidth: null,
268 liveTerminalHeight: null,
269 setSidebarCollapsed: (collapsed) => set({ sidebarCollapsed: collapsed }),
270 setSidebarWidth: (width) => set({ sidebarWidth: width }),
271 setRightDockTreeWidth: (width) => set({ rightDockTreeWidth: width }),
272 setRightDockPreviewWidth: (width) => set({ rightDockPreviewWidth: width }),
273 setWorkspacePanelOpen: (update) => set((s) => ({ workspacePanelOpen: applySetState(s.workspacePanelOpen, update) })),
274 setWorkspacePanelMaximized: (update) => set((s) => ({ workspacePanelMaximized: applySetState(s.workspacePanelMaximized, update) })),
275 setWorkspacePreviewActive: (update) => set((s) => ({ workspacePreviewActive: applySetState(s.workspacePreviewActive, update) })),
276 setRightDockMode: (update) => set((s) => ({ rightDockMode: applySetState(s.rightDockMode, update) })),
277 setTerminalPanelOpen: (update) => set((s) => ({ terminalPanelOpen: applySetState(s.terminalPanelOpen, update) })),
278 setTerminalHeight: (height) => set({ terminalHeight: height }),
279 setSidebarTogglePressed: (pressed) => set({ sidebarTogglePressed: pressed }),
280 setSidebarResizing: (resizing) => set({ sidebarResizing: resizing }),
281 setLiveSidebarWidth: (width) => set({ liveSidebarWidth: width }),
282 setWorkspacePanelResizing: (resizing) => set({ workspacePanelResizing: resizing }),
283 setLiveWorkspacePanelRenderWidth: (width) => set({ liveWorkspacePanelRenderWidth: width }),
284 setLiveTerminalHeight: (height) => set({ liveTerminalHeight: height }),
285 }));
286
286 lines TYPESCRIPT