返回 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, SIDEBAR_AUTO_COLLAPSE_WIDTH } 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_TREE_MIN_WIDTH,
12 saveRightDockTreeWidth,
13 saveSidebarCollapsed,
14 saveSidebarWidth,
15 saveTerminalHeight,
16 SIDEBAR_MAX_WIDTH,
17 SIDEBAR_MIN_WIDTH,
18 terminalMaxHeight,
19 TERMINAL_MIN_HEIGHT,
20 useLayoutStore,
21 useSidebarCollapsed,
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 = useSidebarCollapsed();
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 = rightDockTreeMinWidth;
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 if (viewportWidth < SIDEBAR_AUTO_COLLAPSE_WIDTH) {
154 useWindowChromeStore.setState({ narrowSidebarExpanded: !nextCollapsed });
155 return;
156 }
157 setSidebarCollapsed(nextCollapsed);
158 saveSidebarCollapsed(nextCollapsed);
159 });
160
161 const setExpandedSidebarWidth = useCommittedCommand((width: number) => {
162 closeTransientOverlays();
163 const next = sidebarWidthClamp(width);
164 setSidebarWidth(next);
165 saveSidebarWidth(next);
166 });
167
168 const startSidebarResize = useCommittedCommand((event: ReactPointerEvent<HTMLButtonElement>) => {
169 if (sidebarCollapsed) return;
170 const layout = layoutRef.current;
171 if (!layout) return;
172 event.preventDefault();
173 closeTransientOverlays();
174 setSidebarResizing(true);
175 let nextWidth = sidebarWidth;
176 const liveResize = createRafResizeUpdater({
177 target: layout,
178 separator: event.currentTarget,
179 cssVar: "--sidebar-expanded-width",
180 onApply: setLiveSidebarWidth,
181 });
182 const dockLiveResize = createRafResizeUpdater({
183 target: layout,
184 cssVar: "--workspace-width",
185 onApply: setLiveWorkspacePanelRenderWidth,
186 });
187 const onMove = (moveEvent: PointerEvent) => {
188 nextWidth = sidebarWidthClamp(moveEvent.clientX);
189 liveResize.schedule(nextWidth);
190 dockLiveResize.schedule(resolveLiveWorkspacePanelRenderWidth(preferredWorkspacePanelWidth, nextWidth));
191 };
192 const onDone = () => {
193 liveResize.flush();
194 dockLiveResize.flush();
195 setSidebarWidth(nextWidth);
196 saveSidebarWidth(nextWidth);
197 setLiveSidebarWidth(null);
198 setLiveWorkspacePanelRenderWidth(null);
199 setSidebarResizing(false);
200 window.removeEventListener("pointermove", onMove);
201 window.removeEventListener("pointerup", onDone);
202 window.removeEventListener("pointercancel", onDone);
203 document.body.style.cursor = "";
204 document.body.style.userSelect = "";
205 };
206 document.body.style.cursor = "col-resize";
207 document.body.style.userSelect = "none";
208 window.addEventListener("pointermove", onMove);
209 window.addEventListener("pointerup", onDone);
210 window.addEventListener("pointercancel", onDone);
211 });
212
213 const resizeSidebarWithKeyboard = useCommittedCommand((event: KeyboardEvent<HTMLButtonElement>) => {
214 if (sidebarCollapsed) return;
215 if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
216 event.preventDefault();
217 setExpandedSidebarWidth(sidebarWidth + (event.key === "ArrowRight" ? 16 : -16));
218 } else if (event.key === "Home") {
219 event.preventDefault();
220 setExpandedSidebarWidth(sidebarResizeMinWidth);
221 } else if (event.key === "End") {
222 event.preventDefault();
223 setExpandedSidebarWidth(SIDEBAR_MAX_WIDTH);
224 }
225 });
226
227 const setSavedWorkspacePanelWidth = useCommittedCommand((width: number) => {
228 closeTransientOverlays();
229 const next = rightDockTreeWidthClamp(width, workspacePanelAvailableWidth);
230 setRightDockTreeWidth(next);
231 saveRightDockTreeWidth(next);
232 });
233
234 const ensureWorkspacePanelWidth = useCommittedCommand((width: number) => {
235 if (rightDockMode !== "context") setSavedWorkspacePanelWidth(width);
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 workspacePanelMinWidth,
365 chatReservedWidth,
366 workspacePanelAvailableWidth,
367 workspacePanelRenderWidth,
368 workspacePanelOverlay,
369 workspacePanelRenderable,
370 workspacePanelGridOpen,
371 sidebarRenderWidth,
372 sidebarResizeMinWidth,
373 sidebarWidthClamp,
374 terminalRenderHeight,
375 terminalResizeMaxHeight,
376 };
377 }
378
378 lines TYPESCRIPT