From 2a4678096a3adc156936efd167deb2c8e9c260fd Mon Sep 17 00:00:00 2001 From: Anso Date: Mon, 7 Sep 2026 22:10:24 -0400 Subject: [PATCH] feat(shell): resizable stacks sidebar Add a desktop resize boundary for the stacks sidebar. When the sidebar mode preference is resizable, the shell wraps the sidebar in a width- owned pane with a draggable separator: pointer drag updates the pane live and persists one sanitized width on release; pointercancel, lost pointer capture, and unmount clean up without committing. The separator is keyboard accessible (arrows, Home, End) and exposes role=separator ARIA values that track the effective bounds. The effective width clamps to the viewport through a ResizeObserver on the shell flex row (sidebar plus workspace), reserving a minimum workspace width. Narrowing the window shrinks the pane live without rewriting the stored preference; widening restores the preferred width. Fixed mode (the default) renders the original shell DOM unchanged, and mobile is untouched. --- frontend/src/components/EditorLayout.tsx | 20 +- .../components/sidebar/SidebarResizePane.tsx | 186 +++++++++++++ .../src/components/sidebar/StackSidebar.tsx | 7 +- .../__tests__/SidebarResizePane.test.tsx | 259 ++++++++++++++++++ 4 files changed, 469 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/sidebar/SidebarResizePane.tsx create mode 100644 frontend/src/components/sidebar/__tests__/SidebarResizePane.test.tsx diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index bd982186..4a42c2e4 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -43,6 +43,8 @@ import { useAuth } from '@/context/AuthContext'; import { useDeployFeedback } from '@/context/DeployFeedbackContext'; import { useTrivyStatus } from '@/hooks/useTrivyStatus'; import { StackSidebar } from '@/components/sidebar/StackSidebar'; +import { SidebarResizePane } from '@/components/sidebar/SidebarResizePane'; +import { useSidebarLayout } from '@/hooks/use-sidebar-layout'; import type { StackRowStatus } from '@/components/sidebar/stack-status-utils'; import { useSidebarActivitySummary } from '@/components/sidebar/useSidebarActivitySummary'; import { useNextAutoUpdateRun } from '@/components/sidebar/useNextAutoUpdateRun'; @@ -460,6 +462,8 @@ export default function EditorLayout() { // full-screen stack detail. `mobileView` is explicit state, decoupled from // `activeView`, so 'dashboard' still maps to HomeDashboard everywhere. const isMobile = useIsMobile(); + const { sidebarMode, sidebarWidth, setSidebarWidth } = useSidebarLayout(); + const commitSidebarWidth = useCallback((width: number) => setSidebarWidth(width), [setSidebarWidth]); const [mobileView, setMobileView] = useState('list'); const [mobileSettingsSection, setMobileSettingsSection] = useState(null); // Optimistically flip to the detail surface the instant a row is tapped, @@ -1036,9 +1040,21 @@ export default function EditorLayout() { onClearSelection={clearSelection} onBulkAction={handleBulkAction} showUpdatesChip={sidebarIndicators} + fluid={sidebarMode === 'resizable'} /> ); + // Desktop resizable branch: the pane owns the width and adds the + // separator; Fixed keeps the original shell DOM untouched. + const sidebarSlotEl = !isMobile && sidebarMode === 'resizable' ? ( + + {sidebarEl} + + ) : sidebarEl; + const notificationsEl = ( {commandPaletteEl} {/* Left Sidebar (Stacks) */} - {sidebarEl} + {sidebarSlotEl} {/* Main Content Area */} -
+
{topBarEl} {/* Main Workspace */} {workspaceEl} diff --git a/frontend/src/components/sidebar/SidebarResizePane.tsx b/frontend/src/components/sidebar/SidebarResizePane.tsx new file mode 100644 index 00000000..fadbbe35 --- /dev/null +++ b/frontend/src/components/sidebar/SidebarResizePane.tsx @@ -0,0 +1,186 @@ +import { useCallback, useEffect, useId, useRef, useState, type ReactNode } from 'react'; +import { SIDEBAR_WIDTH, sanitizeSidebarWidth } from '@/hooks/use-sidebar-layout'; + +/** + * Desktop sidebar resize boundary. Wraps the stacks sidebar in a width-owned + * pane and renders the draggable separator beside it. This component is the + * single resize owner: EditorLayout never holds drag state. + * + * Three widths stay separate: the preferred width (the persisted preference, + * never mutated by the viewport), the in-flight live drag width, and the + * effective width actually applied to the pane (preferred clamped to what the + * viewport allows). Narrowing the window shrinks the pane live but does not + * rewrite the preference; widening restores the preferred width. + */ + +/** Workspace px reserved to the right of the sidebar before clamping bites. */ +const MIN_WORKSPACE = 560; +/** Separator hit area in px between sidebar and workspace. */ +const HANDLE_FOOTPRINT = 12; +/** Keyboard step per arrow press, in px. */ +const KEY_STEP = 16; + +interface SidebarResizePaneProps { + sidebarWidth: number; + onCommitWidth: (width: number) => void; + children: ReactNode; +} + +export function SidebarResizePane({ sidebarWidth, onCommitWidth, children }: SidebarResizePaneProps) { + const paneId = useId(); + const paneRef = useRef(null); + const [containerWidth, setContainerWidth] = useState(0); + const [dragging, setDragging] = useState(false); + const dragRef = useRef<{ + pointerId: number; + startX: number; + startWidth: number; + lastWidth: number; + committed: boolean; + } | null>(null); + + // The pane's flex row (its parent: sidebar pane + separator + workspace) + // is the viewport proxy; measuring it avoids window.innerWidth. + useEffect(() => { + const el = paneRef.current?.parentElement; + if (!el) return; + const rect = el.getBoundingClientRect(); + if (rect.width > 0) setContainerWidth(rect.width); + const observer = new ResizeObserver((entries) => { + const width = entries[0]?.contentRect.width; + if (width !== undefined && width > 0) setContainerWidth(width); + }); + observer.observe(el); + return () => observer.disconnect(); + }, []); + + // effectiveMax floors the viewport-derived bound to an integer; at zero + // (before the first measurement) the unclamped bounds apply. + const effectiveMax = containerWidth > 0 + ? Math.floor(Math.max(SIDEBAR_WIDTH.min, Math.min(SIDEBAR_WIDTH.max, containerWidth - MIN_WORKSPACE - HANDLE_FOOTPRINT))) + : SIDEBAR_WIDTH.max; + const effectiveMin = SIDEBAR_WIDTH.min; + const effectiveWidth = Math.min( + effectiveMax, + Math.max(effectiveMin, sanitizeSidebarWidth(sidebarWidth)), + ); + + const applyPaneWidth = useCallback((width: number): void => { + if (paneRef.current) paneRef.current.style.width = `${width}px`; + }, []); + + // Centralized, idempotent teardown for every termination path. A trailing + // lostpointercapture after a committed pointerup is a no-op because the + // committed pointerup cleared dragRef first. + const endDrag = useCallback((commit: boolean): void => { + const drag = dragRef.current; + dragRef.current = null; + if (drag !== null && commit && !drag.committed) { + drag.committed = true; + onCommitWidth(Math.round(drag.lastWidth)); + } + try { + if (drag !== null) paneRef.current?.releasePointerCapture(drag.pointerId); + } catch { + /* capture may already be released */ + } + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + setDragging(false); + }, [onCommitWidth]); + + // Unmount mid-drag still cleans the body styles. + useEffect(() => () => endDrag(false), [endDrag]); + + // The pane is declaratively owned except mid-drag, when pointermove writes + // the style directly (no React state, so only the boundary moves). + useEffect(() => { + if (!dragging && paneRef.current) paneRef.current.style.width = `${effectiveWidth}px`; + }, [effectiveWidth, dragging]); + + const onSeparatorPointerDown = useCallback((event: React.PointerEvent) => { + if (event.button !== 0) return; + event.preventDefault(); + dragRef.current = { + pointerId: event.pointerId, + startX: event.clientX, + startWidth: effectiveWidth, + lastWidth: effectiveWidth, + committed: false, + }; + setDragging(true); + event.currentTarget.setPointerCapture(event.pointerId); + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + }, [effectiveWidth]); + + const onSeparatorPointerMove = useCallback((event: React.PointerEvent) => { + const drag = dragRef.current; + if (drag === null || drag.pointerId !== event.pointerId) return; + const raw = drag.startWidth + (event.clientX - drag.startX); + const next = Math.min(effectiveMax, Math.max(effectiveMin, raw)); + drag.lastWidth = next; + applyPaneWidth(next); + }, [effectiveMax, effectiveMin, applyPaneWidth]); + + const onSeparatorPointerUp = useCallback((event: React.PointerEvent) => { + const drag = dragRef.current; + if (drag === null || drag.pointerId !== event.pointerId) return; + // Snapshot and mark committed BEFORE releasing capture so the trailing + // lostpointercapture finds no drag and cleans up without a second commit. + drag.committed = true; + onCommitWidth(Math.round(drag.lastWidth)); + endDrag(false); + }, [endDrag, onCommitWidth]); + + const onSeparatorKeyDown = useCallback((event: React.KeyboardEvent) => { + let next: number | null = null; + if (event.key === 'Home') { + next = effectiveMin; + } else if (event.key === 'End') { + next = effectiveMax; + } else if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') { + const delta = event.key === 'ArrowRight' ? KEY_STEP : -KEY_STEP; + next = Math.min(effectiveMax, Math.max(effectiveMin, effectiveWidth + delta)); + } + if (next === null) return; + event.preventDefault(); + applyPaneWidth(next); + onCommitWidth(next); + }, [effectiveMax, effectiveMin, effectiveWidth, applyPaneWidth, onCommitWidth]); + + return ( + <> +
+ {children} +
+
endDrag(false)} + onLostPointerCapture={() => endDrag(false)} + onKeyDown={onSeparatorKeyDown} + > + +
+ + ); +} diff --git a/frontend/src/components/sidebar/StackSidebar.tsx b/frontend/src/components/sidebar/StackSidebar.tsx index 1f1ecb25..c566250e 100644 --- a/frontend/src/components/sidebar/StackSidebar.tsx +++ b/frontend/src/components/sidebar/StackSidebar.tsx @@ -39,10 +39,14 @@ export interface StackSidebarProps { filterStale?: boolean; /** False while status evidence is not authoritative; disables bulk buttons. */ actionsReady?: boolean; + /** Fill the parent pane instead of the fixed desktop width (the resizable + * shell pane owns the width); mobile classes are unaffected. */ + fluid?: boolean; } export function StackSidebar(props: StackSidebarProps) { const { + isDarkMode, nodeSwitcherSlot, createStackSlot, onScan, isScanning, canCreate, searchQuery, onSearchChange, filterChip, filterCounts, onFilterChipChange, list, activitySummary, onActivityAction, @@ -50,6 +54,7 @@ export function StackSidebar(props: StackSidebarProps) { showUpdatesChip = true, filterStale = false, actionsReady = false, + fluid = false, } = props; const [filtersVisible, setFiltersVisible] = useState(() => { @@ -70,7 +75,7 @@ export function StackSidebar(props: StackSidebarProps) { return (
{/* On mobile the status masthead leads (it carries the node switcher as its kicker chip), so the in-sidebar brand and node rows are redundant diff --git a/frontend/src/components/sidebar/__tests__/SidebarResizePane.test.tsx b/frontend/src/components/sidebar/__tests__/SidebarResizePane.test.tsx new file mode 100644 index 00000000..346bdebb --- /dev/null +++ b/frontend/src/components/sidebar/__tests__/SidebarResizePane.test.tsx @@ -0,0 +1,259 @@ +/** + * Coverage for SidebarResizePane, the desktop sidebar resize boundary. + * + * The pane owns the width: dragging writes the pane style directly (no React + * state per move) and only a pointerup commits a preference write; every + * cancellation path (pointercancel, lost capture, unmount) cleans the body + * styles without committing. A ResizeObserver on the shell flex row drives + * the effective bounds so a narrow window clamps live without rewriting the + * stored width. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, waitFor, fireEvent, act } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { SidebarResizePane } from '../SidebarResizePane'; +import { SIDEBAR_WIDTH, SIDEBAR_WIDTH_KEY, useSidebarLayout } from '@/hooks/use-sidebar-layout'; +import { subscribeToPreferenceWrites } from '@/lib/preferences/preferenceEvents'; + +const OriginalResizeObserver = globalThis.ResizeObserver; +const observed: { el: Element; cb: ResizeObserverCallback }[] = []; +let shellRowWidth = 0; + +function shellRowRect(width: number): DOMRect { + return { + width, + height: 800, + top: 0, + left: 0, + bottom: 800, + right: width, + x: 0, + y: 0, + toJSON: () => ({}), + } as DOMRect; +} + +function notifyShellRowResize(): void { + for (const { el, cb } of [...observed]) { + cb( + [{ + target: el, + contentRect: el.getBoundingClientRect(), + borderBoxSize: [], + contentBoxSize: [], + devicePixelContentBoxSize: [], + } as unknown as ResizeObserverEntry], + {} as ResizeObserver, + ); + } +} + +function pane(): HTMLElement { + return screen.getByTestId('sidebar-resize-pane'); +} + +function separator(): HTMLElement { + return screen.getByRole('separator', { name: 'Resize stacks sidebar' }); +} + +function dragSeparator(fromX: number, toX: number): void { + const sep = separator(); + fireEvent.pointerDown(sep, { pointerId: 1, clientX: fromX }); + fireEvent.pointerMove(sep, { pointerId: 1, clientX: toX }); + fireEvent.pointerUp(sep, { pointerId: 1, clientX: toX }); +} + +// Harness mirroring the real wiring: width state comes from the shared +// preference hook, so a commit lands in localStorage and notifies the bus +// exactly like the shell does. +function Harness({ onCommit }: { onCommit?: (w: number) => void }) { + const { sidebarWidth, setSidebarWidth } = useSidebarLayout(); + return ( +
+ { setSidebarWidth(w); onCommit?.(w); }} + > +
sidebar
+
+
workspace
+
+ ); +} + +function setup(onCommit?: (w: number) => void) { + return render(); +} + +describe('SidebarResizePane', () => { + beforeEach(() => { + localStorage.clear(); + observed.length = 0; + shellRowWidth = 1200; + const origRect = HTMLElement.prototype.getBoundingClientRect; + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { + // The pane's parent is the shell flex row (pane + separator + workspace). + if (this.querySelector?.('[data-testid="workspace"]')) return shellRowRect(shellRowWidth); + return origRect.call(this); + }); + globalThis.ResizeObserver = class MockShellRowResizeObserver { + cb: ResizeObserverCallback; + constructor(cb: ResizeObserverCallback) { + this.cb = cb; + } + observe(el: Element) { + observed.push({ el, cb: this.cb }); + this.cb( + [{ + target: el, + contentRect: el.getBoundingClientRect(), + borderBoxSize: [], + contentBoxSize: [], + devicePixelContentBoxSize: [], + } as unknown as ResizeObserverEntry], + this as unknown as ResizeObserver, + ); + } + unobserve() {} + disconnect() {} + } as unknown as typeof ResizeObserver; + }); + + afterEach(() => { + globalThis.ResizeObserver = OriginalResizeObserver; + vi.restoreAllMocks(); + }); + + it('applies the preferred width on mount and renders the separator with live ARIA bounds', async () => { + localStorage.setItem(SIDEBAR_WIDTH_KEY, '320'); + setup(); + await waitFor(() => { + expect(pane().style.width).toBe('320px'); + expect(separator()).toHaveAttribute('aria-valuenow', '320'); + expect(separator()).toHaveAttribute('aria-valuemin', String(SIDEBAR_WIDTH.min)); + expect(separator()).toHaveAttribute('aria-valuemax', String(SIDEBAR_WIDTH.max)); + }); + }); + + it('clamps the live drag to the viewport bound without rewriting storage', async () => { + shellRowWidth = 900; // effectiveMax = 900 - 560 - 12 = 328 + const commits: number[] = []; + setup((w) => commits.push(w)); + await waitFor(() => expect(separator()).toHaveAttribute('aria-valuemax', '328')); + dragSeparator(300, 900); + expect(pane().style.width).toBe('328px'); + expect(commits).toEqual([328]); + expect(localStorage.getItem(SIDEBAR_WIDTH_KEY)).toBe('328'); + }); + + it('emits exactly one sanitized integer commit per drag', async () => { + localStorage.setItem(SIDEBAR_WIDTH_KEY, '280'); + const commits: number[] = []; + setup((w) => commits.push(w)); + const sep = separator(); + fireEvent.pointerDown(sep, { pointerId: 1, clientX: 300 }); + fireEvent.pointerMove(sep, { pointerId: 1, clientX: 321.6 }); // fractional pointer delta + fireEvent.pointerMove(sep, { pointerId: 1, clientX: 332.2 }); + fireEvent.pointerUp(sep, { pointerId: 1, clientX: 332.2 }); + expect(commits).toEqual([312]); + expect(Number.isInteger(commits[0])).toBe(true); + expect(pane().style.width).toBe('312px'); + }); + + it('writes pane style per move without any preference write until release', async () => { + localStorage.setItem(SIDEBAR_WIDTH_KEY, '280'); + const notify = vi.fn(); + const unsub = subscribeToPreferenceWrites(notify); + const commits: number[] = []; + setup((w) => commits.push(w)); + const sep = separator(); + fireEvent.pointerDown(sep, { pointerId: 1, clientX: 300 }); + fireEvent.pointerMove(sep, { pointerId: 1, clientX: 380 }); + expect(pane().style.width).toBe('360px'); + expect(commits).toEqual([]); + expect(notify).not.toHaveBeenCalled(); + fireEvent.pointerUp(sep, { pointerId: 1, clientX: 380 }); + expect(commits).toEqual([360]); + expect(notify).toHaveBeenCalledTimes(1); + unsub(); + }); + + it('tears down on pointercancel without committing and restores the effective width', async () => { + localStorage.setItem(SIDEBAR_WIDTH_KEY, '280'); + const commits: number[] = []; + setup((w) => commits.push(w)); + const sep = separator(); + fireEvent.pointerDown(sep, { pointerId: 1, clientX: 300 }); + fireEvent.pointerMove(sep, { pointerId: 1, clientX: 380 }); + expect(document.body.style.cursor).toBe('col-resize'); + expect(document.body.style.userSelect).toBe('none'); + fireEvent.pointerCancel(sep, { pointerId: 1, clientX: 0 }); + expect(commits).toEqual([]); + expect(pane().style.width).toBe('280px'); + expect(document.body.style.cursor).toBe(''); + expect(document.body.style.userSelect).toBe(''); + fireEvent.pointerMove(sep, { pointerId: 1, clientX: 500 }); + fireEvent.pointerUp(sep, { pointerId: 1, clientX: 500 }); + expect(commits).toEqual([]); + }); + + it('finishes on lostpointercapture without committing and ignores a trailing one after pointerup', async () => { + localStorage.setItem(SIDEBAR_WIDTH_KEY, '280'); + const commits: number[] = []; + setup((w) => commits.push(w)); + const sep = separator(); + // Unexpected capture loss mid-drag: no commit. + fireEvent.pointerDown(sep, { pointerId: 1, clientX: 300 }); + fireEvent.pointerMove(sep, { pointerId: 1, clientX: 380 }); + fireEvent.lostPointerCapture(sep, { pointerId: 1, clientX: 0 }); + expect(commits).toEqual([]); + expect(pane().style.width).toBe('280px'); + expect(document.body.style.cursor).toBe(''); + // Committed pointerup, then the browser's trailing lostpointercapture. + dragSeparator(300, 340); + fireEvent.lostPointerCapture(sep, { pointerId: 1, clientX: 0 }); + expect(commits).toEqual([320]); + expect(pane().style.width).toBe('320px'); + }); + + it('clears body resize styles if the pane unmounts mid-drag', async () => { + const { unmount } = setup(); + fireEvent.pointerDown(separator(), { pointerId: 1, clientX: 300 }); + expect(document.body.style.cursor).toBe('col-resize'); + unmount(); + expect(document.body.style.cursor).toBe(''); + expect(document.body.style.userSelect).toBe(''); + }); + + it('shrinks the live pane on a narrow shell without rewriting storage, then restores on widen', async () => { + localStorage.setItem(SIDEBAR_WIDTH_KEY, '400'); + shellRowWidth = 900; // effectiveMax = 328 + const commits: number[] = []; + setup((w) => commits.push(w)); + await waitFor(() => expect(pane().style.width).toBe('328px')); + expect(localStorage.getItem(SIDEBAR_WIDTH_KEY)).toBe('400'); + shellRowWidth = 1200; + act(() => { notifyShellRowResize(); }); + await waitFor(() => expect(pane().style.width).toBe('400px')); + expect(localStorage.getItem(SIDEBAR_WIDTH_KEY)).toBe('400'); + expect(commits).toEqual([]); + }); + + it('commits Home/End and arrow-key widths and updates ARIA live', async () => { + localStorage.setItem(SIDEBAR_WIDTH_KEY, '300'); + const user = userEvent.setup(); + const commits: number[] = []; + shellRowWidth = 1200; + setup((w) => commits.push(w)); + separator().focus(); + await user.keyboard('{ArrowRight}'); + expect(commits).toEqual([316]); + expect(separator()).toHaveAttribute('aria-valuenow', '316'); + await user.keyboard('{Home}'); + expect(pane().style.width).toBe(`${SIDEBAR_WIDTH.min}px`); + await user.keyboard('{End}'); + expect(pane().style.width).toBe(`${SIDEBAR_WIDTH.max}px`); + expect(separator()).toHaveAttribute('aria-valuemax', String(SIDEBAR_WIDTH.max)); + expect(commits).toEqual([316, SIDEBAR_WIDTH.min, SIDEBAR_WIDTH.max]); + }); +});