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
@@ -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');
});
});