import { type ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { useT } from '../../../lib/i18n/I18nContext'; import { useAppDispatch, useAppSelector } from '../../../store/hooks'; import { ensurePanelLayout, selectPanelLayout, setSidebarVisible, setSidebarWidth, toggleSidebar, } from '../../../store/layoutSlice'; import { Tooltip } from '../../ui'; import CollapsedNavRail from './CollapsedNavRail'; // `app-shell` (not the older `root-shell`) so the persisted geometry seeds // fresh with the sidebar visible by default. Exported so the global command // layer (mod+B "Toggle sidebar") can target this exact panel. export const APP_SHELL_LAYOUT_ID = 'app-shell'; const LAYOUT_ID = APP_SHELL_LAYOUT_ID; const DEFAULT_WIDTH = 224; const MIN_WIDTH = 188; const MAX_WIDTH = 420; const KEYBOARD_STEP = 16; const LAYOUT_DEFAULTS = { sidebarVisible: true, sidebarWidth: DEFAULT_WIDTH }; function clamp(width: number): number { return Math.min(Math.max(width, MIN_WIDTH), MAX_WIDTH); } /** * Subscribe to the root shell sidebar's visibility and get helpers to drive it * from chrome that lives elsewhere (e.g. the in-sidebar header's collapse * button, or a reshow button in the content area). */ export function useRootSidebar() { const dispatch = useAppDispatch(); const layout = useAppSelector(selectPanelLayout(LAYOUT_ID, LAYOUT_DEFAULTS)); return { visible: layout.sidebarVisible, toggle: useCallback(() => dispatch(toggleSidebar({ id: LAYOUT_ID })), [dispatch]), show: useCallback( () => dispatch(setSidebarVisible({ id: LAYOUT_ID, visible: true })), [dispatch] ), hide: useCallback( () => dispatch(setSidebarVisible({ id: LAYOUT_ID, visible: false })), [dispatch] ), }; } export interface RootShellLayoutProps { /** Always-visible left pane (the app sidebar). */ sidebar: ReactNode; /** Dynamic main content (the routed page area). */ children: ReactNode; } /** * Full-bleed, viewport-filling two-pane shell for the app root: a resizable * sidebar on the left and the main content on the right, separated by a flush * hairline seam. Unlike the in-page {@link TwoPanelLayout}, this fills its * container edge-to-edge (no card, no rounded corners) because it *is* the * window chrome. The dragged width persists per user via the `layout` slice * (id `root-shell`); the sidebar is always shown. */ export default function RootShellLayout({ sidebar, children }: RootShellLayoutProps) { const { t } = useT(); const dispatch = useAppDispatch(); const layout = useAppSelector(selectPanelLayout(LAYOUT_ID, LAYOUT_DEFAULTS)); const persistedWidth = clamp(layout.sidebarWidth); const isOpen = layout.sidebarVisible; // Seed persisted geometry once so the selector returns a stable stored // reference on subsequent renders (avoids the new-object memoization warning). useEffect(() => { dispatch(ensurePanelLayout({ id: LAYOUT_ID, defaults: LAYOUT_DEFAULTS })); }, [dispatch]); const [dragWidth, setDragWidth] = useState(null); const dragWidthRef = useRef(null); const dragCleanupRef = useRef<(() => void) | null>(null); const width = dragWidth ?? persistedWidth; const commitWidth = useCallback( (next: number) => dispatch(setSidebarWidth({ id: LAYOUT_ID, width: clamp(Math.round(next)) })), [dispatch] ); const onPointerDown = useCallback( (e: React.PointerEvent) => { e.preventDefault(); const startX = e.clientX; const startWidth = width; dragWidthRef.current = startWidth; setDragWidth(startWidth); document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; function handleMove(ev: PointerEvent) { const next = clamp(startWidth + (ev.clientX - startX)); dragWidthRef.current = next; setDragWidth(next); } function detach() { window.removeEventListener('pointermove', handleMove); window.removeEventListener('pointerup', stop); window.removeEventListener('pointercancel', stop); window.removeEventListener('blur', stop); document.body.style.removeProperty('cursor'); document.body.style.removeProperty('user-select'); dragCleanupRef.current = null; } function stop() { detach(); const finalWidth = dragWidthRef.current; dragWidthRef.current = null; setDragWidth(null); if (finalWidth != null) commitWidth(finalWidth); } dragCleanupRef.current = detach; window.addEventListener('pointermove', handleMove); window.addEventListener('pointerup', stop); window.addEventListener('pointercancel', stop); window.addEventListener('blur', stop); }, [width, commitWidth] ); // Detach global listeners if we unmount mid-drag. useLayoutEffect(() => () => dragCleanupRef.current?.(), []); const onDividerKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === 'ArrowLeft') { e.preventDefault(); commitWidth(persistedWidth - KEYBOARD_STEP); } else if (e.key === 'ArrowRight') { e.preventDefault(); commitWidth(persistedWidth + KEYBOARD_STEP); } }, [commitWidth, persistedWidth] ); return (
{isOpen && ( <>
{sidebar}
)} {/* Reshow affordance — only when the sidebar is collapsed. A thin rail that occupies layout space (NOT an overlay) so the content — and the native CEF webview glued to the content's bounds, which composites above the HTML layer — starts to its right and never covers it. */} {!isOpen && (
{/* macOS overlay title bar (titleBarStyle: Overlay) floats the traffic lights over the top-left. The expanded SidebarHeader dodges them by right-aligning, but this narrow rail can't — so reserve a draggable strip the height of the window controls and start the rail below it, clear of the lights. */}
{/* Keep the primary nav reachable while collapsed: an icon-only rail. */}
)}
{children}
); }