返回 DeepSeek-Reasonix
useShellGeometry.ts
根目录 / desktop / frontend / src / app-runtime / useShellGeometry.ts
1 import { useEffect, useRef, type KeyboardEvent, type PointerEvent as ReactPointerEvent, type RefObject } from "react";
2 import { useCommittedCommand } from "../lib/useCommittedCommand";
3 import { createPointerResizeLifecycle, createRafResizeUpdater } from "../lib/resizeDrag";
4 import { availableWorkspacePanelWidth, resolveLiveWorkspacePanelWidth, resolveWorkspacePanelPlacement } from "../lib/workspaceLayout";
5 import { useOverlayStore } from "../store/overlays";
6 import { useWindowChromeStore } from "../store/windowChrome";
7 import {
8 clampRightDockTreeWidth,
9 clampSidebarWidth,
10 clampTerminalHeight,
11 RIGHT_DOCK_MIN_RENDER_WIDTH,
12 RIGHT_DOCK_TREE_MIN_WIDTH,
13 saveRightDockTreeWidth,
14 saveSidebarCollapsed,
15 saveSidebarWidth,
16 saveTerminalHeight,
17 SIDEBAR_MAX_WIDTH,
18 SIDEBAR_MIN_WIDTH,
19 terminalMaxHeight,
20 TERMINAL_MIN_HEIGHT,
21 useLayoutStore,
22 } from "../store/layout";
23
24 const CHAT_MIN_WIDTH = 400;
25 const WORKSPACE_RESIZER_WIDTH = 8;
26
27 /**
28 * Owns the shell geometry commands and their read projections: sidebar and
29 * right-dock/terminal pointer and keyboard resizing, the sidebar toggle, and
30 * the derived widths consumed by App JSX and the region prop builders. All
31 * transient geometry lives on the layout store; the refs are injected because
32 * the resizers drive CSS variables on the root layout element.
33 */
34 export function useShellGeometry(input: { appRef: RefObject<HTMLDivElement | null>; layoutRef: RefObject<HTMLDivElement | null> }) {
35 const { appRef, layoutRef } = input;
36 const viewportWidth = useWindowChromeStore((state) => state.viewportWidth);
37 const viewportHeight = useWindowChromeStore((state) => state.viewportHeight);
38 const sidebarCollapsed = useLayoutStore((state) => state.sidebarCollapsed);
39 const sidebarWidth = useLayoutStore((state) => state.sidebarWidth);
40 const liveSidebarWidth = useLayoutStore((state) => state.liveSidebarWidth);
41 const rightDockTreeWidth = useLayoutStore((state) => state.rightDockTreeWidth);
42 const liveWorkspacePanelRenderWidth = useLayoutStore((state) => state.liveWorkspacePanelRenderWidth);
43 const workspacePanelOpen = useLayoutStore((state) => state.workspacePanelOpen);
44 const workspacePanelMaximized = useLayoutStore((state) => state.workspacePanelMaximized);
45 const rightDockMode = useLayoutStore((state) => state.rightDockMode);
46 const terminalPanelOpen = useLayoutStore((state) => state.terminalPanelOpen);
47 const terminalHeight = useLayoutStore((state) => state.terminalHeight);
48 const setSidebarCollapsed = useLayoutStore((state) => state.setSidebarCollapsed);
49 const setSidebarWidth = useLayoutStore((state) => state.setSidebarWidth);
50 const setRightDockTreeWidth = useLayoutStore((state) => state.setRightDockTreeWidth);
51 const setTerminalHeight = useLayoutStore((state) => state.setTerminalHeight);
52 const setSidebarTogglePressed = useLayoutStore((state) => state.setSidebarTogglePressed);
53 const setSidebarResizing = useLayoutStore((state) => state.setSidebarResizing);
54 const setLiveSidebarWidth = useLayoutStore((state) => state.setLiveSidebarWidth);
55 const setWorkspacePanelResizing = useLayoutStore((state) => state.setWorkspacePanelResizing);
56 const setLiveWorkspacePanelRenderWidth = useLayoutStore((state) => state.setLiveWorkspacePanelRenderWidth);
57 const setLiveTerminalHeight = useLayoutStore((state) => state.setLiveTerminalHeight);
58 const setSidebarSearchOpen = useOverlayStore((state) => state.setSidebarSearchOpen);
59 const setTransientOverlayDismissSignal = useOverlayStore((state) => state.setTransientOverlayDismissSignal);
60
61 const closeTransientOverlays = useCommittedCommand(() => {
62 setTransientOverlayDismissSignal((signal) => signal + 1);
63 });
64
65 // The dock keeps one width across tab switches (context/files/changed):
66 // the tree width is the single source so toggling tabs never resizes the
67 // sidebar. Preview detail stays inside the dock without widening it.
68 const preferredWorkspacePanelWidth = rightDockTreeWidth;
69 const rightDockTreeMinWidth = RIGHT_DOCK_TREE_MIN_WIDTH;
70 const rightDockTreeWidthClamp = clampRightDockTreeWidth;
71 const rightDockMinRenderWidth = RIGHT_DOCK_MIN_RENDER_WIDTH;
72 const workspacePanelMinWidth = rightDockTreeMinWidth;
73 const chatReservedWidth = CHAT_MIN_WIDTH;
74 const workspacePanelAvailableWidth = availableWorkspacePanelWidth({
75 viewportWidth,
76 sidebarCollapsed,
77 sidebarWidth,
78 chatMinWidth: chatReservedWidth,
79 resizerWidth: WORKSPACE_RESIZER_WIDTH,
80 });
81 const {
82 renderWidth: workspacePanelRenderWidth,
83 overlay: workspacePanelOverlay,
84 renderable: workspacePanelRenderable,
85 gridOpen: workspacePanelGridOpen,
86 } = resolveWorkspacePanelPlacement({
87 viewportWidth, sidebarCollapsed, sidebarWidth, chatMinWidth: chatReservedWidth,
88 resizerWidth: WORKSPACE_RESIZER_WIDTH, open: workspacePanelOpen,
89 maximized: workspacePanelMaximized, preferredWidth: preferredWorkspacePanelWidth,
90 minWidth: workspacePanelMinWidth, minRenderWidth: rightDockMinRenderWidth,
91 liveWidth: liveWorkspacePanelRenderWidth,
92 });
93 const resolveLiveWorkspacePanelRenderWidth = useCommittedCommand((preferredWidth: number, nextSidebarWidth = sidebarWidth) =>
94 resolveLiveWorkspacePanelWidth({
95 viewportWidth,
96 sidebarCollapsed,
97 sidebarWidth: nextSidebarWidth,
98 chatMinWidth: chatReservedWidth,
99 resizerWidth: WORKSPACE_RESIZER_WIDTH,
100 open: workspacePanelOpen,
101 maximized: workspacePanelMaximized,
102 preferredWidth,
103 minWidth: workspacePanelMinWidth,
104 }));
105
106 const sidebarWidthClamp = clampSidebarWidth;
107 const sidebarRenderWidth = liveSidebarWidth ?? sidebarWidth;
108 const sidebarResizeMinWidth = SIDEBAR_MIN_WIDTH;
109 const terminalRenderHeight = clampTerminalHeight(terminalHeight, viewportHeight);
110 const terminalResizeMaxHeight = terminalMaxHeight(viewportHeight);
111
112 const sidebarTogglePressTimerRef = useRef<number | null>(null);
113 const workspacePanelResizeFinishRef = useRef<(() => void) | null>(null);
114 const anchorPinTimerRef = useRef<number | null>(null);
115 const anchorPinFrameRef = useRef<number | null>(null);
116 useEffect(() => () => {
117 if (sidebarTogglePressTimerRef.current !== null) window.clearTimeout(sidebarTogglePressTimerRef.current);
118 if (anchorPinTimerRef.current !== null) window.clearTimeout(anchorPinTimerRef.current);
119 if (anchorPinFrameRef.current !== null) window.cancelAnimationFrame(anchorPinFrameRef.current);
120 workspacePanelResizeFinishRef.current?.();
121 }, []);
122
123 const pulseSidebarToggle = useCommittedCommand(() => {
124 if (typeof window === "undefined") return;
125 if (sidebarTogglePressTimerRef.current !== null) {
126 window.clearTimeout(sidebarTogglePressTimerRef.current);
127 }
128 setSidebarTogglePressed(true);
129 sidebarTogglePressTimerRef.current = window.setTimeout(() => {
130 sidebarTogglePressTimerRef.current = null;
131 setSidebarTogglePressed(false);
132 }, 260);
133 });
134
135 const anchorAppScrollToChat = useCommittedCommand(() => {
136 if (typeof window === "undefined") return;
137 const el = appRef.current;
138 if (!el) return;
139 const pin = () => {
140 el.scrollLeft = 0;
141 };
142 pin();
143 anchorPinFrameRef.current = window.requestAnimationFrame(pin);
144 anchorPinTimerRef.current = window.setTimeout(pin, 300);
145 });
146
147 const toggleSidebar = useCommittedCommand(() => {
148 closeTransientOverlays();
149 pulseSidebarToggle();
150 anchorAppScrollToChat();
151 const nextCollapsed = !sidebarCollapsed;
152 if (nextCollapsed) setSidebarSearchOpen(false);
153 setSidebarCollapsed(nextCollapsed);
154 saveSidebarCollapsed(nextCollapsed);
155 });
156
157 const setExpandedSidebarWidth = useCommittedCommand((width: number) => {
158 closeTransientOverlays();
159 const next = sidebarWidthClamp(width);
160 setSidebarWidth(next);
161 saveSidebarWidth(next);
162 });
163
164 const startSidebarResize = useCommittedCommand((event: ReactPointerEvent<HTMLButtonElement>) => {
165 if (sidebarCollapsed) return;
166 const layout = layoutRef.current;
167 if (!layout) return;
168 event.preventDefault();
169 closeTransientOverlays();
170 setSidebarResizing(true);
171 let nextWidth = sidebarWidth;
172 const liveResize = createRafResizeUpdater({
173 target: layout,
174 separator: event.currentTarget,
175 cssVar: "--sidebar-expanded-width",
176 onApply: setLiveSidebarWidth,
177 });
178 const dockLiveResize = createRafResizeUpdater({
179 target: layout,
180 cssVar: "--workspace-width",
181 onApply: setLiveWorkspacePanelRenderWidth,
182 });
183 const onMove = (moveEvent: PointerEvent) => {
184 nextWidth = sidebarWidthClamp(moveEvent.clientX);
185 liveResize.schedule(nextWidth);
186 dockLiveResize.schedule(resolveLiveWorkspacePanelRenderWidth(preferredWorkspacePanelWidth, nextWidth));
187 };
188 const onDone = () => {
189 liveResize.flush();
190 dockLiveResize.flush();
191 setSidebarWidth(nextWidth);
192 saveSidebarWidth(nextWidth);
193 setLiveSidebarWidth(null);
194 setLiveWorkspacePanelRenderWidth(null);
195 setSidebarResizing(false);
196 window.removeEventListener("pointermove", onMove);
197 window.removeEventListener("pointerup", onDone);
198 window.removeEventListener("pointercancel", onDone);
199 document.body.style.cursor = "";
200 document.body.style.userSelect = "";
201 };
202 document.body.style.cursor = "col-resize";
203 document.body.style.userSelect = "none";
204 window.addEventListener("pointermove", onMove);
205 window.addEventListener("pointerup", onDone);
206 window.addEventListener("pointercancel", onDone);
207 });
208
209 const resizeSidebarWithKeyboard = useCommittedCommand((event: KeyboardEvent<HTMLButtonElement>) => {
210 if (sidebarCollapsed) return;
211 if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
212 event.preventDefault();
213 setExpandedSidebarWidth(sidebarWidth + (event.key === "ArrowRight" ? 16 : -16));
214 } else if (event.key === "Home") {
215 event.preventDefault();
216 setExpandedSidebarWidth(sidebarResizeMinWidth);
217 } else if (event.key === "End") {
218 event.preventDefault();
219 setExpandedSidebarWidth(SIDEBAR_MAX_WIDTH);
220 }
221 });
222
223 const setSavedWorkspacePanelWidth = useCommittedCommand((width: number) => {
224 closeTransientOverlays();
225 const next = rightDockTreeWidthClamp(width, workspacePanelAvailableWidth);
226 setRightDockTreeWidth(next);
227 saveRightDockTreeWidth(next);
228 });
229
230 const ensureWorkspacePanelWidth = useCommittedCommand((width: number) => {
231 closeTransientOverlays();
232 if (rightDockMode === "context") return;
233 const next = rightDockTreeWidthClamp(width, workspacePanelAvailableWidth);
234 setRightDockTreeWidth(next);
235 saveRightDockTreeWidth(next);
236 });
237
238 const startWorkspacePanelResize = useCommittedCommand((event: ReactPointerEvent<HTMLButtonElement>) => {
239 if (event.button !== 0 || !workspacePanelOpen) return;
240 const layout = layoutRef.current;
241 if (!layout) return;
242 event.preventDefault();
243 workspacePanelResizeFinishRef.current?.();
244 closeTransientOverlays();
245 setWorkspacePanelResizing(true);
246 const separator = event.currentTarget;
247 const pointerId = event.pointerId;
248 const startX = event.clientX;
249 const startDockWidth = workspacePanelRenderWidth;
250 let nextDockWidth = startDockWidth;
251 const liveResize = createRafResizeUpdater({
252 target: layout,
253 separator,
254 cssVar: "--workspace-width",
255 onApply: setLiveWorkspacePanelRenderWidth,
256 });
257 const onMove = (moveEvent: PointerEvent) => {
258 const delta = moveEvent.clientX - startX;
259 nextDockWidth = startDockWidth - delta;
260 nextDockWidth = rightDockTreeWidthClamp(nextDockWidth, workspacePanelAvailableWidth);
261 liveResize.schedule(resolveLiveWorkspacePanelRenderWidth(nextDockWidth));
262 };
263 const lifecycle = createPointerResizeLifecycle({
264 separator,
265 pointerId,
266 onMove,
267 onFinish: () => {
268 liveResize.flush();
269 setSavedWorkspacePanelWidth(nextDockWidth);
270 setLiveWorkspacePanelRenderWidth(null);
271 setWorkspacePanelResizing(false);
272 workspacePanelResizeFinishRef.current = null;
273 document.body.style.cursor = "";
274 document.body.style.userSelect = "";
275 },
276 });
277 workspacePanelResizeFinishRef.current = lifecycle.finish;
278 document.body.style.cursor = "col-resize";
279 document.body.style.userSelect = "none";
280 });
281
282 const resizeWorkspacePanelWithKeyboard = useCommittedCommand((event: KeyboardEvent<HTMLButtonElement>) => {
283 if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
284 event.preventDefault();
285 setSavedWorkspacePanelWidth(workspacePanelRenderWidth + (event.key === "ArrowLeft" ? 16 : -16));
286 } else if (event.key === "Home") {
287 event.preventDefault();
288 setSavedWorkspacePanelWidth(rightDockTreeMinWidth);
289 } else if (event.key === "End") {
290 event.preventDefault();
291 setSavedWorkspacePanelWidth(workspacePanelAvailableWidth);
292 }
293 });
294
295 const setSavedTerminalHeight = useCommittedCommand((height: number) => {
296 const next = clampTerminalHeight(height, viewportHeight);
297 setTerminalHeight(next);
298 saveTerminalHeight(next);
299 });
300
301 const startTerminalResize = useCommittedCommand((event: ReactPointerEvent<HTMLButtonElement>) => {
302 if (!terminalPanelOpen) return;
303 const layout = layoutRef.current;
304 if (!layout) return;
305 event.preventDefault();
306 closeTransientOverlays();
307 const startY = event.clientY;
308 const startHeight = terminalRenderHeight;
309 let nextHeight = startHeight;
310 const liveResize = createRafResizeUpdater({
311 target: layout,
312 separator: event.currentTarget,
313 cssVar: "--terminal-height",
314 onApply: setLiveTerminalHeight,
315 });
316 const onMove = (moveEvent: PointerEvent) => {
317 const delta = startY - moveEvent.clientY;
318 nextHeight = clampTerminalHeight(startHeight + delta, viewportHeight);
319 liveResize.schedule(nextHeight);
320 };
321 const onDone = () => {
322 liveResize.flush();
323 setLiveTerminalHeight(null);
324 setSavedTerminalHeight(nextHeight);
325 window.removeEventListener("pointermove", onMove);
326 window.removeEventListener("pointerup", onDone);
327 window.removeEventListener("pointercancel", onDone);
328 document.body.style.cursor = "";
329 document.body.style.userSelect = "";
330 };
331 document.body.style.cursor = "row-resize";
332 document.body.style.userSelect = "none";
333 window.addEventListener("pointermove", onMove);
334 window.addEventListener("pointerup", onDone);
335 window.addEventListener("pointercancel", onDone);
336 });
337
338 const resizeTerminalWithKeyboard = useCommittedCommand((event: KeyboardEvent<HTMLButtonElement>) => {
339 if (!terminalPanelOpen) return;
340 if (event.key === "ArrowUp" || event.key === "ArrowDown") {
341 event.preventDefault();
342 setSavedTerminalHeight(terminalRenderHeight + (event.key === "ArrowUp" ? 16 : -16));
343 } else if (event.key === "Home") {
344 event.preventDefault();
345 setSavedTerminalHeight(TERMINAL_MIN_HEIGHT);
346 } else if (event.key === "End") {
347 event.preventDefault();
348 setSavedTerminalHeight(terminalResizeMaxHeight);
349 }
350 });
351
352 return {
353 toggleSidebar,
354 setExpandedSidebarWidth,
355 startSidebarResize,
356 resizeSidebarWithKeyboard,
357 setSavedWorkspacePanelWidth,
358 ensureWorkspacePanelWidth,
359 startWorkspacePanelResize,
360 resizeWorkspacePanelWithKeyboard,
361 setSavedTerminalHeight,
362 startTerminalResize,
363 resizeTerminalWithKeyboard,
364 rightDockTreeMinWidth,
365 rightDockTreeWidthClamp,
366 workspacePanelMinWidth,
367 chatReservedWidth,
368 workspacePanelAvailableWidth,
369 workspacePanelRenderWidth,
370 workspacePanelOverlay,
371 workspacePanelRenderable,
372 workspacePanelGridOpen,
373 sidebarRenderWidth,
374 sidebarResizeMinWidth,
375 sidebarWidthClamp,
376 terminalRenderHeight,
377 terminalResizeMaxHeight,
378 };
379 }
380
380 lines TYPESCRIPT