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.
This commit is contained in:
Anso
2026-08-05 12:50:11 -04:00
committed by GitHub
parent a826cd398d
commit 85b841175a
3 changed files with 446 additions and 8 deletions
+3 -3
View File
@@ -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.
@@ -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<HTMLDivElement>(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<HTMLDivElement>): 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<HTMLDivElement>): 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<HTMLDivElement>): 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<HTMLDivElement>): 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<FileRoot[]>([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 (
<div className="flex h-full min-h-0">
<div ref={explorerRef} data-testid="file-explorer" className="flex h-full min-h-0">
{/* Left pane: root switcher + tree + upload + new folder */}
<div className="flex flex-col w-56 shrink-0 border-r border-glass-border min-h-0">
<div
id={treePaneId}
data-testid="file-explorer-tree-pane"
className="flex min-h-0 min-w-0 shrink-0 flex-col overflow-hidden"
style={{ width: layoutWidth }}
>
<div className="flex flex-col gap-1 px-2 py-1.5 border-b border-glass-border shrink-0">
<span className="text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">Browsing</span>
<Select value={selectedRootId} onValueChange={handleRootChange}>
@@ -590,6 +724,27 @@ export function StackFileExplorer({
</div>
</div>
<div
role="separator"
aria-orientation="vertical"
aria-label="Resize file tree"
aria-controls={treePaneId}
aria-valuenow={layoutWidth}
aria-valuemin={MIN_TREE_WIDTH}
aria-valuemax={layoutMax}
aria-valuetext={`${layoutWidth} pixels`}
tabIndex={0}
className="relative z-10 w-px shrink-0 cursor-col-resize touch-none bg-glass-border outline-none hover:bg-brand focus-visible:bg-brand focus-visible:ring-1 focus-visible:ring-brand/50"
onPointerDown={onTreeResizePointerDown}
onPointerMove={onTreeResizePointerMove}
onPointerUp={finishTreeResize}
onPointerCancel={finishTreeResize}
onLostPointerCapture={finishTreeResize}
onKeyDown={onTreeResizeKeyDown}
>
<span className="absolute inset-y-0 left-0 -right-1.5 z-10" aria-hidden />
</div>
{/* Right pane: action bar + viewer */}
<div className="flex flex-col flex-1 min-h-0 min-w-0">
{selectedPath !== null && (
@@ -7,8 +7,8 @@
* exposes a "Mark dirty" button so the test can drive the dirty signal
* without instantiating Monaco.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { FileEntry } from '@/lib/stackFilesApi';
import { downloadBlob } from '@/lib/download';
@@ -454,3 +454,286 @@ describe('StackFileExplorer new file affordance', () => {
expect(screen.queryByRole('button', { name: 'New file' })).not.toBeInTheDocument();
});
});
describe('StackFileExplorer resizable tree pane', () => {
const TREE_WIDTH_KEY = 'sencho.fileExplorer.treeWidth';
const OriginalResizeObserver = globalThis.ResizeObserver;
const observed: { el: Element; cb: ResizeObserverCallback }[] = [];
let explorerWidth = 0;
function treePane(): HTMLElement {
return screen.getByTestId('file-explorer-tree-pane');
}
function separator(): HTMLElement {
return screen.getByRole('separator', { name: 'Resize file tree' });
}
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 });
}
function explorerRect(width: number): DOMRect {
return {
width,
height: 400,
top: 0,
left: 0,
bottom: 400,
right: width,
x: 0,
y: 0,
toJSON: () => ({}),
} as DOMRect;
}
function notifyExplorerResize(): void {
for (const { el, cb } of [...observed]) {
cb(
[{
target: el,
contentRect: el.getBoundingClientRect(),
borderBoxSize: [],
contentBoxSize: [],
devicePixelContentBoxSize: [],
} as unknown as ResizeObserverEntry],
{} as ResizeObserver,
);
}
}
beforeEach(() => {
localStorage.clear();
explorerWidth = 0;
observed.length = 0;
const origRect = HTMLElement.prototype.getBoundingClientRect;
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
if (this.dataset.testid === 'file-explorer') return explorerRect(explorerWidth);
return origRect.call(this);
});
globalThis.ResizeObserver = class MockExplorerResizeObserver {
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('defaults to 224px with shrink-0 when nothing is stored', () => {
setup();
const pane = treePane();
expect(pane.style.width).toBe('224px');
expect(pane.className).toMatch(/\bshrink-0\b/);
expect(pane.className).toMatch(/\bmin-w-0\b/);
expect(pane.className).toMatch(/\boverflow-hidden\b/);
expect(pane.className).not.toMatch(/\bw-56\b/);
});
it('restores a stored in-range width', () => {
localStorage.setItem(TREE_WIDTH_KEY, '360');
setup();
expect(treePane().style.width).toBe('360px');
});
it('accepts stored exact min and max bounds', () => {
localStorage.setItem(TREE_WIDTH_KEY, '160');
const { unmount } = setup();
expect(treePane().style.width).toBe('160px');
unmount();
localStorage.setItem(TREE_WIDTH_KEY, '560');
setup();
expect(treePane().style.width).toBe('560px');
});
it('falls back to 224 for out-of-range or corrupt storage', () => {
for (const raw of ['99', '9999', 'nope', '{bad']) {
localStorage.setItem(TREE_WIDTH_KEY, raw);
const { unmount } = setup();
expect(treePane().style.width).toBe('224px');
unmount();
}
});
it('widens by the drag delta and persists the pointer-up position', () => {
setup();
const sep = separator();
fireEvent.pointerDown(sep, { pointerId: 1, clientX: 400 });
fireEvent.pointerMove(sep, { pointerId: 1, clientX: 420 });
expect(treePane().style.width).toBe('244px');
expect(localStorage.getItem(TREE_WIDTH_KEY)).toBeNull();
fireEvent.pointerUp(sep, { pointerId: 1, clientX: 460 });
expect(treePane().style.width).toBe('284px');
expect(localStorage.getItem(TREE_WIDTH_KEY)).toBe('284');
expect(document.body.style.cursor).toBe('');
expect(document.body.style.userSelect).toBe('');
});
it('clamps a far-left drag to 160 and persists that bound', () => {
setup();
dragSeparator(400, 0);
expect(treePane().style.width).toBe('160px');
expect(localStorage.getItem(TREE_WIDTH_KEY)).toBe('160');
});
it('clamps a far-right drag to 560 and persists that bound', () => {
setup();
dragSeparator(400, 2000);
expect(treePane().style.width).toBe('560px');
expect(localStorage.getItem(TREE_WIDTH_KEY)).toBe('560');
});
it('caps the tree so a narrow explorer still leaves room for the viewer', async () => {
explorerWidth = 500;
setup();
await waitFor(() => expect(separator()).toHaveAttribute('aria-valuemax', '260'));
dragSeparator(400, 2000);
expect(treePane().style.width).toBe('260px');
expect(localStorage.getItem(TREE_WIDTH_KEY)).toBe('260');
});
it('shrinks the live pane on a narrow layout without rewriting storage', async () => {
localStorage.setItem(TREE_WIDTH_KEY, '560');
explorerWidth = 500;
setup();
await waitFor(() => expect(treePane().style.width).toBe('260px'));
expect(localStorage.getItem(TREE_WIDTH_KEY)).toBe('560');
explorerWidth = 900;
notifyExplorerResize();
await waitFor(() => expect(treePane().style.width).toBe('560px'));
expect(localStorage.getItem(TREE_WIDTH_KEY)).toBe('560');
});
it('tears down the gesture and persists on pointercancel', () => {
setup();
const sep = separator();
fireEvent.pointerDown(sep, { pointerId: 1, clientX: 400 });
fireEvent.pointerMove(sep, { pointerId: 1, clientX: 420 });
expect(treePane().style.width).toBe('244px');
expect(document.body.style.cursor).toBe('col-resize');
expect(document.body.style.userSelect).toBe('none');
fireEvent.pointerCancel(sep, { pointerId: 1, clientX: 0 });
expect(treePane().style.width).toBe('244px');
expect(localStorage.getItem(TREE_WIDTH_KEY)).toBe('244');
expect(document.body.style.cursor).toBe('');
expect(document.body.style.userSelect).toBe('');
fireEvent.pointerMove(sep, { pointerId: 1, clientX: 600 });
fireEvent.pointerUp(sep, { pointerId: 1, clientX: 600 });
expect(treePane().style.width).toBe('244px');
expect(localStorage.getItem(TREE_WIDTH_KEY)).toBe('244');
});
it('finishes the gesture on lostpointercapture using the last live width', () => {
setup();
const sep = separator();
fireEvent.pointerDown(sep, { pointerId: 1, clientX: 400 });
fireEvent.pointerMove(sep, { pointerId: 1, clientX: 420 });
fireEvent.lostPointerCapture(sep, { pointerId: 1, clientX: 0 });
expect(treePane().style.width).toBe('244px');
expect(localStorage.getItem(TREE_WIDTH_KEY)).toBe('244');
expect(document.body.style.cursor).toBe('');
fireEvent.pointerMove(sep, { pointerId: 1, clientX: 500 });
expect(treePane().style.width).toBe('244px');
});
it('ignores a trailing lostpointercapture after pointerup', () => {
setup();
const sep = separator();
fireEvent.pointerDown(sep, { pointerId: 1, clientX: 400 });
fireEvent.pointerMove(sep, { pointerId: 1, clientX: 420 });
fireEvent.pointerUp(sep, { pointerId: 1, clientX: 420 });
fireEvent.lostPointerCapture(sep, { pointerId: 1, clientX: 0 });
expect(treePane().style.width).toBe('244px');
expect(localStorage.getItem(TREE_WIDTH_KEY)).toBe('244');
});
it('clears body resize styles if the explorer unmounts mid-drag', () => {
const { unmount } = setup();
fireEvent.pointerDown(separator(), { pointerId: 1, clientX: 400 });
expect(document.body.style.cursor).toBe('col-resize');
expect(document.body.style.userSelect).toBe('none');
unmount();
expect(document.body.style.cursor).toBe('');
expect(document.body.style.userSelect).toBe('');
});
it('nudges 8px on ArrowRight and stops at the max bound', async () => {
const user = userEvent.setup();
const { unmount } = setup();
separator().focus();
await user.keyboard('{ArrowRight}');
expect(treePane().style.width).toBe('232px');
expect(localStorage.getItem(TREE_WIDTH_KEY)).toBe('232');
unmount();
localStorage.setItem(TREE_WIDTH_KEY, '556');
setup();
separator().focus();
await user.keyboard('{ArrowRight}{ArrowRight}');
expect(treePane().style.width).toBe('560px');
expect(localStorage.getItem(TREE_WIDTH_KEY)).toBe('560');
});
it('nudges 8px on ArrowLeft and stops at the min bound', async () => {
const user = userEvent.setup();
const { unmount } = setup();
separator().focus();
await user.keyboard('{ArrowLeft}');
expect(treePane().style.width).toBe('216px');
expect(localStorage.getItem(TREE_WIDTH_KEY)).toBe('216');
unmount();
localStorage.setItem(TREE_WIDTH_KEY, '164');
setup();
separator().focus();
await user.keyboard('{ArrowLeft}{ArrowLeft}');
expect(treePane().style.width).toBe('160px');
expect(localStorage.getItem(TREE_WIDTH_KEY)).toBe('160');
});
it('jumps to min and max on Home and End', async () => {
const user = userEvent.setup();
setup();
separator().focus();
await user.keyboard('{Home}');
expect(treePane().style.width).toBe('160px');
expect(localStorage.getItem(TREE_WIDTH_KEY)).toBe('160');
await user.keyboard('{End}');
expect(treePane().style.width).toBe('560px');
expect(localStorage.getItem(TREE_WIDTH_KEY)).toBe('560');
});
it('caps End at the live layout max', async () => {
const user = userEvent.setup();
explorerWidth = 500;
setup();
await waitFor(() => expect(separator()).toHaveAttribute('aria-valuemax', '260'));
separator().focus();
await user.keyboard('{End}');
expect(treePane().style.width).toBe('260px');
expect(localStorage.getItem(TREE_WIDTH_KEY)).toBe('260');
});
});