diff --git a/apps/fabro-web/app/routes/run-files.tsx b/apps/fabro-web/app/routes/run-files.tsx index c13676101..cff6da6d7 100644 --- a/apps/fabro-web/app/routes/run-files.tsx +++ b/apps/fabro-web/app/routes/run-files.tsx @@ -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(null); const lastDeepLinkToastRef = useRef(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 ; + return ; } // 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}
{!narrow ? ( - + }> 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( () => - 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) => - search.setValue(event.target.value); + setFilterText(event.target.value); const themeStyles = useMemo( () => themeToTreeStyles(pierreDark) as TreeThemeStyle, @@ -167,20 +173,29 @@ export function FileTreeSidebar({ ); } diff --git a/apps/fabro-web/app/routes/run-files/states.test.tsx b/apps/fabro-web/app/routes/run-files/states.test.tsx index 0e49c8424..03487ac30 100644 --- a/apps/fabro-web/app/routes/run-files/states.test.tsx +++ b/apps/fabro-web/app/routes/run-files/states.test.tsx @@ -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(); + }); + 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; diff --git a/apps/fabro-web/app/routes/run-files/states.tsx b/apps/fabro-web/app/routes/run-files/states.tsx index 02080d939..0ca7fa5ab 100644 --- a/apps/fabro-web/app/routes/run-files/states.tsx +++ b/apps/fabro-web/app/routes/run-files/states.tsx @@ -98,12 +98,38 @@ export function deriveEmptyKind(args: { return "unknown"; } -export function LoadingSkeleton() { +export function FileTreeSidebarSkeleton() { + return ( +