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