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