| 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 |