mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-07 08:27:12 +00:00
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:
parent
0f5280d857
commit
be63bda5c8
9 changed files with 218 additions and 142 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
2
lib/crates/fabro-spa/assets/assets/app.css
generated
2
lib/crates/fabro-spa/assets/assets/app.css
generated
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
4
lib/crates/fabro-spa/assets/index.html
generated
4
lib/crates/fabro-spa/assets/index.html
generated
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue