From 85b841175a2e2827dea87d8f27657615ce4d3465 Mon Sep 17 00:00:00 2001 From: Anso Date: Wed, 5 Aug 2026 12:50:11 -0400 Subject: [PATCH] feat: make the Files explorer tree pane resizable (#1774) * feat: make the Files explorer tree pane resizable Operators can drag the Files tab divider to read long or deeply nested names. The width is remembered in this browser; names that still overflow scroll horizontally. * fix: keep Files tree resize from leaking cursor or stealing row clicks Unmount and lost-pointer-capture now clear the drag gesture. The stored width stays a preference while layout clamps to the explorer size, and the hit target only expands into the viewer so full-row context menus still work. * fix: persist last live Files pane width on pointercancel Cancel events can report clientX 0, which was clamping the tree to 160px and writing that to storage. Commit the last tracked width instead, same as lostpointercapture. --- docs/features/stack-file-explorer.mdx | 6 +- .../components/files/StackFileExplorer.tsx | 161 +++++++++- .../__tests__/StackFileExplorer.test.tsx | 287 +++++++++++++++++- 3 files changed, 446 insertions(+), 8 deletions(-) diff --git a/docs/features/stack-file-explorer.mdx b/docs/features/stack-file-explorer.mdx index eef7cc41..e8d27c9e 100644 --- a/docs/features/stack-file-explorer.mdx +++ b/docs/features/stack-file-explorer.mdx @@ -54,7 +54,7 @@ Named-volume editing is best-effort and bound by file ownership. The helper cont ## Layout -The Files tab splits into two panes. The left pane holds the Browsing selector, the upload affordance, the **New file** and **New folder** buttons, and the directory tree for the selected root. The right pane is the action bar plus the file viewer. +The Files tab splits into two panes. The left pane holds the Browsing selector, the upload affordance, the **New file** and **New folder** buttons, and the directory tree for the selected root. The right pane is the action bar plus the file viewer. Drag the divider between the panes to widen or narrow the tree; the width is remembered in this browser. A full-screen toggle sits next to **Close editor** whenever the Files tab is active. It collapses the identity, health, and log column on the left of the stack workspace so the tree and viewer use the full width, which helps when you are working with wide config files or a deep directory tree. Click it again (or switch to a different tab) to return to the normal two-column layout. @@ -68,9 +68,9 @@ Folders sort before files, and entries within each group sort alphabetically. Click a folder to expand or collapse it. Click a file to open it in the viewer on the right. Symlinks render with a chain icon and behave like files when clicked. Deleting a symlink removes only the link entry; the file it points to is untouched. -The tree is fully keyboard navigable. Tab into it and use the arrow keys to move between rows: **Up** and **Down** move row to row, **Right** expands a folder (then steps into it), **Left** collapses it (or steps out to the parent), **Home** and **End** jump to the first and last visible row, and **Enter** or **Space** opens a file or toggles a folder. +The tree is fully keyboard navigable. Tab into it and use the arrow keys to move between rows: **Up** and **Down** move row to row, **Right** expands a folder (then steps into it), **Left** collapses it (or steps out to the parent), **Home** and **End** jump to the first and last visible row, and **Enter** or **Space** opens a file or toggles a folder. Tab to the divider between the tree and the viewer and use **Left** or **Right** to nudge its width by 8px, or **Home** and **End** to jump to the narrowest and widest sizes. -Each row is fully clickable across the pane, so right-clicking anywhere on a row (not just on its name) opens that entry's context menu. Long names are never truncated: the tree scrolls horizontally so you can read the full name. +Each row is fully clickable across the pane, so right-clicking anywhere on a row (not just on its name) opens that entry's context menu. Long names are never truncated. Widen the tree with the divider when you can; names that still overflow the pane scroll horizontally. **Display cap.** Each directory render is capped at 1000 entries. A folder with more than 1000 children shows the first 1000 alphabetically with a footer noting how many entries the directory holds in total. The tree also has a filter input at the top of the list so you can narrow a large directory to the entries you care about without dropping to a shell. diff --git a/frontend/src/components/files/StackFileExplorer.tsx b/frontend/src/components/files/StackFileExplorer.tsx index a4dda80c..2c456ee6 100644 --- a/frontend/src/components/files/StackFileExplorer.tsx +++ b/frontend/src/components/files/StackFileExplorer.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback, useMemo } from 'react'; +import { useState, useEffect, useCallback, useId, useMemo, useRef, type PointerEvent, type KeyboardEvent } from 'react'; import { Trash2, FilePlus, FolderPlus, FolderInput, Download, Loader2, AlertTriangle, X } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { ConfirmModal } from '@/components/ui/modal'; @@ -25,6 +25,42 @@ interface StackFileExplorerProps { onNavigateToEnv?: () => void; } +const DEFAULT_TREE_WIDTH = 224; +const MIN_TREE_WIDTH = 160; +const MAX_TREE_WIDTH = 560; +const MIN_VIEWER_WIDTH = 240; +const TREE_WIDTH_KEY = 'sencho.fileExplorer.treeWidth'; + +function clampTreeWidth(n: number, containerWidth = 0): number { + const max = containerWidth > 0 + ? Math.min(MAX_TREE_WIDTH, Math.max(MIN_TREE_WIDTH, containerWidth - MIN_VIEWER_WIDTH)) + : MAX_TREE_WIDTH; + return Math.min(max, Math.max(MIN_TREE_WIDTH, n)); +} + +function readStoredTreeWidth(): number { + try { + const n = Number.parseInt(localStorage.getItem(TREE_WIDTH_KEY) ?? '', 10); + if (Number.isFinite(n) && n >= MIN_TREE_WIDTH && n <= MAX_TREE_WIDTH) return n; + } catch { + /* localStorage may be unavailable */ + } + return DEFAULT_TREE_WIDTH; +} + +function persistTreeWidth(n: number): void { + try { + localStorage.setItem(TREE_WIDTH_KEY, String(n)); + } catch { + /* localStorage may be unavailable */ + } +} + +function clearBodyResizeStyles(): void { + document.body.style.cursor = ''; + document.body.style.userSelect = ''; +} + /** The synthetic stack-source root used before roots load or if discovery fails. */ const STACK_SOURCE_FALLBACK: FileRoot = { id: STACK_SOURCE_ROOT_ID, @@ -71,6 +107,81 @@ export function StackFileExplorer({ const [currentDir, setCurrentDir] = useState(''); const [refreshKey, setRefreshKey] = useState(0); const [isDownloading, setIsDownloading] = useState(false); + const [treeWidth, setTreeWidth] = useState(readStoredTreeWidth); + const [containerWidth, setContainerWidth] = useState(0); + const explorerRef = useRef(null); + const treePaneId = useId(); + const dragRef = useRef<{ + pointerId: number; + startX: number; + startWidth: number; + lastWidth: number; + } | null>(null); + const layoutWidth = clampTreeWidth(treeWidth, containerWidth); + const layoutMax = clampTreeWidth(MAX_TREE_WIDTH, containerWidth); + + function clampToLayout(n: number): number { + return clampTreeWidth(n, containerWidth); + } + + function commitTreeWidth(next: number): void { + setTreeWidth(next); + persistTreeWidth(next); + } + + function finishTreeResize(event: PointerEvent): void { + const drag = dragRef.current; + if (drag === null || drag.pointerId !== event.pointerId) return; + const next = event.type === 'pointerup' + ? clampToLayout(drag.startWidth + (event.clientX - drag.startX)) + : drag.lastWidth; + dragRef.current = null; + try { + event.currentTarget.releasePointerCapture(event.pointerId); + } catch { + /* capture may already be released */ + } + clearBodyResizeStyles(); + commitTreeWidth(next); + } + + function onTreeResizePointerDown(event: PointerEvent): void { + if (event.button !== 0) return; + event.preventDefault(); + dragRef.current = { + pointerId: event.pointerId, + startX: event.clientX, + startWidth: layoutWidth, + lastWidth: layoutWidth, + }; + event.currentTarget.setPointerCapture(event.pointerId); + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + } + + function onTreeResizePointerMove(event: PointerEvent): void { + const drag = dragRef.current; + if (drag === null || drag.pointerId !== event.pointerId) return; + const next = clampToLayout(drag.startWidth + (event.clientX - drag.startX)); + drag.lastWidth = next; + setTreeWidth(next); + } + + function onTreeResizeKeyDown(event: KeyboardEvent): void { + if (event.key === 'Home') { + event.preventDefault(); + commitTreeWidth(MIN_TREE_WIDTH); + return; + } + if (event.key === 'End') { + event.preventDefault(); + commitTreeWidth(layoutMax); + return; + } + if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return; + event.preventDefault(); + commitTreeWidth(clampToLayout(layoutWidth + (event.key === 'ArrowRight' ? 8 : -8))); + } // ── file roots (Volumes + Stack source) ── const [roots, setRoots] = useState([STACK_SOURCE_FALLBACK]); @@ -134,6 +245,24 @@ export function StackFileExplorer({ const [isViewerDirty, setIsViewerDirty] = useState(false); const [pendingSelection, setPendingSelection] = useState<{ relPath: string; entry: FileEntry } | null>(null); + useEffect(() => { + return () => { + clearBodyResizeStyles(); + }; + }, []); + + useEffect(() => { + const el = explorerRef.current; + if (!el) return; + setContainerWidth(el.getBoundingClientRect().width); + const observer = new ResizeObserver((entries) => { + const width = entries[0]?.contentRect.width; + if (width !== undefined) setContainerWidth(width); + }); + observer.observe(el); + return () => observer.disconnect(); + }, []); + useEffect(() => { setSelectedPath(null); setSelectedEntry(null); @@ -431,9 +560,14 @@ export function StackFileExplorer({ }, []); return ( -
+
{/* Left pane: root switcher + tree + upload + new folder */} -
+
Browsing