fix(web): stabilize run files sidebar interactions

Reserve the desktop file-tree column while files and the lazy sidebar load, and make the custom changed-files filter drive the rendered tree directly.
This commit is contained in:
Bryan Helmkamp 2026-04-25 09:34:40 -04:00
parent 0f5280d857
commit be63bda5c8
No known key found for this signature in database
9 changed files with 218 additions and 142 deletions

View file

@ -22,6 +22,7 @@ import {
import {
deriveEmptyKind,
EmptyState,
FileTreeSidebarSkeleton,
InlineErrorBanner,
LoadingSkeleton,
renderStatusError,
@ -51,6 +52,10 @@ export const handle = { wide: true };
const MD_BREAKPOINT_PX = 768;
const DIFF_STYLE_STORAGE_KEY = "fabro.run-files.diff-style";
// Minimum time the Refresh button keeps spinning after a click. SWR can
// resolve a cached/304 refetch in tens of ms, leaving the user unsure
// whether the click registered.
const MIN_REFRESH_SPIN_MS = 500;
export const ErrorBoundary = RunFilesErrorBoundary;
@ -261,15 +266,32 @@ export default function RunFiles() {
const containerRef = useRef<HTMLDivElement | null>(null);
const lastDeepLinkToastRef = useRef<string | null>(null);
// Return focus to the Refresh button after a revalidation completes so
const [minSpinUntil, setMinSpinUntil] = useState(0);
const handleRefresh = useCallback(() => {
setMinSpinUntil(Date.now() + MIN_REFRESH_SPIN_MS);
void filesQuery.mutate();
}, [filesQuery]);
useEffect(() => {
if (minSpinUntil === 0) return;
const remaining = minSpinUntil - Date.now();
if (remaining <= 0) {
setMinSpinUntil(0);
return;
}
const id = window.setTimeout(() => setMinSpinUntil(0), remaining);
return () => window.clearTimeout(id);
}, [minSpinUntil]);
const showRefreshing = isRevalidating || minSpinUntil > 0;
// Return focus to the Refresh button after a refresh visibly completes so
// keyboard-first users stay oriented.
const refreshingPrev = useRef(false);
useEffect(() => {
if (refreshingPrev.current && !isRevalidating) {
if (refreshingPrev.current && !showRefreshing) {
refreshButtonRef.current?.focus({ preventScroll: true });
}
refreshingPrev.current = isRevalidating;
}, [isRevalidating]);
refreshingPrev.current = showRefreshing;
}, [showRefreshing]);
const fileCount = data?.data.length ?? 0;
useFileKeyboardNav(containerRef, fileCount);
@ -367,7 +389,7 @@ export default function RunFiles() {
);
if (isInitialLoading) {
return <LoadingSkeleton />;
return <LoadingSkeleton reserveSidebar={!narrow} />;
}
// Initial load failed with no prior data to fall back on. The route
@ -410,8 +432,8 @@ export default function RunFiles() {
additions: meta.stats.additions,
deletions: meta.stats.deletions,
}}
onRefresh={() => void filesQuery.mutate()}
refreshing={isRevalidating}
onRefresh={handleRefresh}
refreshing={showRefreshing}
refreshDisabled={refreshDisabled}
freshness={freshness}
refreshButtonRef={refreshButtonRef}
@ -479,7 +501,7 @@ export default function RunFiles() {
) : null}
<div className="flex gap-4">
{!narrow ? (
<Suspense fallback={null}>
<Suspense fallback={<FileTreeSidebarSkeleton />}>
<FileTreeSidebar
files={files}
selectedPath={hashFile}

View file

@ -2,13 +2,13 @@ import {
useEffect,
useMemo,
useRef,
useState,
type ChangeEvent,
type CSSProperties,
} from "react";
import {
FileTree,
useFileTree,
useFileTreeSearch,
useFileTreeSelection,
} from "@pierre/trees/react";
import {
@ -80,16 +80,25 @@ export function FileTreeSidebar({
selectedPath,
onSelect,
}: FileTreeSidebarProps) {
const paths = useMemo(() => files.map(filePath), [files]);
const [filterText, setFilterText] = useState("");
const normalizedFilter = filterText.trim().toLowerCase();
const filteredFiles = useMemo(
() =>
normalizedFilter
? files.filter((file) => filePath(file).toLowerCase().includes(normalizedFilter))
: files,
[files, normalizedFilter],
);
const paths = useMemo(() => filteredFiles.map(filePath), [filteredFiles]);
const changedPaths = useMemo(() => new Set(paths), [paths]);
const gitStatus = useMemo<GitStatusEntry[]>(
() =>
files.map((file) => ({
filteredFiles.map((file) => ({
path: filePath(file),
status: gitStatusFor(file),
})),
[files],
[filteredFiles],
);
const onSelectRef = useRef(onSelect);
@ -111,8 +120,6 @@ export function FileTreeSidebar({
gitStatus,
icons: "standard",
density: "default",
search: true,
fileTreeSearchMode: "hide-non-matches",
onSelectionChange: (selected) => {
const selectedFile = lastSelectedFile(selected, changedPathsRef.current);
if (!selectedFile) return;
@ -154,9 +161,8 @@ export function FileTreeSidebar({
);
}, [changedPaths, model, selectedPath, selection]);
const search = useFileTreeSearch(model);
const handleSearchChange = (event: ChangeEvent<HTMLInputElement>) =>
search.setValue(event.target.value);
setFilterText(event.target.value);
const themeStyles = useMemo(
() => themeToTreeStyles(pierreDark) as TreeThemeStyle,
@ -167,20 +173,29 @@ export function FileTreeSidebar({
<aside
aria-label="Changed files"
style={themeStyles}
className="sticky top-4 flex max-h-[calc(100vh-6rem)] w-72 shrink-0 flex-col gap-2 self-start"
className="sticky top-4 flex h-[calc(100vh-6rem)] w-72 shrink-0 flex-col gap-2 self-start"
>
<input
type="search"
value={search.value}
value={filterText}
onChange={handleSearchChange}
placeholder="Filter changed files…"
aria-label="Filter changed files"
className="w-full rounded-md border border-line bg-panel px-2 py-1.5 text-sm text-fg placeholder:text-fg-muted focus:outline-2 focus:outline-focus focus:outline-offset-2"
/>
<FileTree
model={model}
className="min-h-0 flex-1 overflow-hidden rounded-md border border-line bg-panel"
/>
{paths.length > 0 ? (
<FileTree
model={model}
className="min-h-0 flex-1 overflow-hidden rounded-md border border-line bg-panel"
/>
) : (
<div
role="status"
className="min-h-0 flex-1 rounded-md border border-line bg-panel px-3 py-2 text-sm text-fg-muted"
>
No matching files
</div>
)}
</aside>
);
}

View file

@ -172,6 +172,19 @@ describe("component rendering", () => {
expect(labeled.length).toBeGreaterThan(0);
});
test("LoadingSkeleton can reserve desktop sidebar space", () => {
let tree: TestRenderer.ReactTestRenderer | undefined;
TestRenderer.act(() => {
tree = TestRenderer.create(<LoadingSkeleton reserveSidebar />);
});
const sidebarSkeleton = tree!.root.findAll(
(node) =>
node.props?.["aria-hidden"] === "true" &&
String(node.props?.className ?? "").includes("w-72"),
);
expect(sidebarSkeleton.length).toBe(1);
});
test("InlineErrorBanner fires onRetry when clicked", () => {
let clicked = 0;
let tree: TestRenderer.ReactTestRenderer | undefined;

View file

@ -98,12 +98,38 @@ export function deriveEmptyKind(args: {
return "unknown";
}
export function LoadingSkeleton() {
export function FileTreeSidebarSkeleton() {
return (
<div
aria-hidden="true"
className="sticky top-4 h-[calc(100vh-6rem)] w-72 shrink-0 self-start rounded-md border border-line bg-panel/40 motion-safe:animate-pulse"
/>
);
}
export function LoadingSkeleton({
reserveSidebar = false,
}: {
reserveSidebar?: boolean;
} = {}) {
const diffSkeleton = (
<div className="flex min-w-0 flex-1 flex-col gap-3">
<div className="h-32 rounded-md bg-panel/60 motion-safe:animate-pulse" />
<div className="h-32 rounded-md bg-panel/60 motion-safe:animate-pulse" />
</div>
);
return (
<div className="flex flex-col gap-3" aria-label="Loading files">
<div className="h-8 rounded-md bg-panel/60 motion-safe:animate-pulse" />
<div className="h-32 rounded-md bg-panel/60 motion-safe:animate-pulse" />
<div className="h-32 rounded-md bg-panel/60 motion-safe:animate-pulse" />
{reserveSidebar ? (
<div className="flex gap-4">
<FileTreeSidebarSkeleton />
{diffSkeleton}
</div>
) : (
diffSkeleton
)}
</div>
);
}

View file

@ -87,7 +87,7 @@ export function Toolbar({
className="relative inline-flex size-7 items-center justify-center rounded-md border border-line bg-panel text-fg-3 transition-colors hover:bg-overlay hover:text-fg disabled:cursor-default disabled:opacity-60 disabled:hover:bg-panel disabled:hover:text-fg-3"
>
<ArrowPathIcon
className={`size-3.5 ${refreshing ? "animate-spin" : ""}`}
className={`size-3.5 ${refreshing ? "animate-spin [animation-duration:450ms]" : ""}`}
aria-hidden="true"
/>
<span

File diff suppressed because one or more lines are too long

View file

@ -59,7 +59,7 @@
<script type="module" src="/assets/chunk-nkw6kj41.js"></script>
<script type="module" src="/assets/chunk-71s03bbh.js"></script>
<script type="module" src="/assets/chunk-ept66kdn.js"></script>
<script type="module" src="/assets/entry-t8vkx078.js"></script>
<script type="module" src="/assets/entry-6kgat4hc.js"></script>
<script type="module" src="/assets/chunk-dep0g6mr.js"></script>
<script type="module" src="/assets/chunk-c8zhk10v.js"></script>
<script type="module" src="/assets/chunk-n9tc6j4j.js"></script>
@ -68,7 +68,7 @@
<script type="module" src="/assets/chunk-z868q2s0.js"></script>
<script type="module" src="/assets/chunk-gf0502ds.js"></script>
<script type="module" src="/assets/chunk-a2vqe77y.js"></script>
<script type="module" src="/assets/chunk-y7k3vxdt.js"></script>
<script type="module" src="/assets/chunk-mdm0g258.js"></script>
<script type="module" src="/assets/chunk-txd1j6f3.js"></script>
<script type="module" src="/assets/chunk-5c4a681q.js"></script>
<script type="module" src="/assets/chunk-7jvnrb0q.js"></script>