diff --git a/apps/fabro-web/app/routes/run-files.tsx b/apps/fabro-web/app/routes/run-files.tsx index d90c7c1f4..c13676101 100644 --- a/apps/fabro-web/app/routes/run-files.tsx +++ b/apps/fabro-web/app/routes/run-files.tsx @@ -1,4 +1,6 @@ import { + lazy, + Suspense, useCallback, useEffect, useRef, @@ -26,7 +28,6 @@ import { RunFilesErrorBoundary, } from "./run-files/states"; import { useFileKeyboardNav } from "./run-files/keyboard"; -import { FileTreeSidebar } from "./run-files/file-tree-sidebar"; import { Toolbar, type DiffStyle } from "./run-files/toolbar"; import { ApiError, extractRequestId } from "../lib/api-client"; import { useRun, useRunFiles } from "../lib/queries"; @@ -34,6 +35,11 @@ import { useRun, useRunFiles } from "../lib/queries"; export { extractRequestId }; const { MultiFileDiff, PatchDiff } = PierreDiffs; +const FileTreeSidebar = lazy(() => + import("./run-files/file-tree-sidebar").then((module) => ({ + default: module.FileTreeSidebar, + })), +); const maybeVirtualizer = (PierreDiffs as Record).Virtualizer; const Virtualizer = typeof maybeVirtualizer === "function" ? maybeVirtualizer as ({ children }: { children: ReactNode }) => ReactElement @@ -49,7 +55,10 @@ const DIFF_STYLE_STORAGE_KEY = "fabro.run-files.diff-style"; export const ErrorBoundary = RunFilesErrorBoundary; function useNarrowViewport(): boolean { - const [narrow, setNarrow] = useState(false); + const [narrow, setNarrow] = useState(() => { + if (typeof window === "undefined") return false; + return window.matchMedia(`(max-width: ${MD_BREAKPOINT_PX - 1}px)`).matches; + }); useEffect(() => { if (typeof window === "undefined") return; const mql = window.matchMedia(`(max-width: ${MD_BREAKPOINT_PX - 1}px)`); @@ -469,11 +478,15 @@ export default function RunFiles() { /> ) : null}
- + {!narrow ? ( + + + + ) : null}
{body}
diff --git a/apps/fabro-web/app/routes/run-files/file-tree-sidebar.tsx b/apps/fabro-web/app/routes/run-files/file-tree-sidebar.tsx index 1ee0c5915..785d472f0 100644 --- a/apps/fabro-web/app/routes/run-files/file-tree-sidebar.tsx +++ b/apps/fabro-web/app/routes/run-files/file-tree-sidebar.tsx @@ -1,9 +1,9 @@ import { useEffect, - useLayoutEffect, useMemo, useRef, type ChangeEvent, + type CSSProperties, } from "react"; import { FileTree, @@ -11,10 +11,17 @@ import { useFileTreeSearch, useFileTreeSelection, } from "@pierre/trees/react"; -import { themeToTreeStyles, type GitStatus, type GitStatusEntry } from "@pierre/trees"; +import { + themeToTreeStyles, + type FileTree as FileTreeModel, + type GitStatus, + type GitStatusEntry, +} from "@pierre/trees"; import pierreDark from "@pierre/theme/pierre-dark"; import type { FileDiff } from "@qltysh/fabro-api-client"; +type TreeThemeStyle = CSSProperties & Record<`--${string}`, string | number>; + const CHANGE_KIND_TO_GIT_STATUS: Record, GitStatus> = { added: "added", modified: "modified", @@ -28,16 +35,38 @@ function filePath(file: FileDiff): string { return file.new_file.name || file.old_file.name; } -function ancestorPaths(paths: readonly string[]): string[] { - const set = new Set(); - for (const p of paths) { - let i = p.indexOf("/"); - while (i !== -1) { - set.add(p.slice(0, i)); - i = p.indexOf("/", i + 1); +function gitStatusFor(file: FileDiff): GitStatus { + return file.change_kind + ? CHANGE_KIND_TO_GIT_STATUS[file.change_kind] ?? "modified" + : "modified"; +} + +function lastSelectedFile( + selected: readonly string[], + changedPaths: ReadonlySet, +): string | null { + for (let index = selected.length - 1; index >= 0; index -= 1) { + const path = selected[index]; + if (path && changedPaths.has(path)) return path; + } + return null; +} + +function syncSelection( + model: FileTreeModel, + selection: readonly string[], + selectedPath: string | null, +) { + for (const path of selection) { + if (path !== selectedPath) { + model.getItem(path)?.deselect(); } } - return [...set]; + if (!selectedPath || (selection.length === 1 && selection[0] === selectedPath)) { + return; + } + const item = model.getItem(selectedPath); + if (item && !item.isSelected()) item.select(); } interface FileTreeSidebarProps { @@ -52,24 +81,32 @@ export function FileTreeSidebar({ onSelect, }: FileTreeSidebarProps) { const paths = useMemo(() => files.map(filePath), [files]); + const changedPaths = useMemo(() => new Set(paths), [paths]); const gitStatus = useMemo( () => files.map((file) => ({ path: filePath(file), - status: file.change_kind - ? CHANGE_KIND_TO_GIT_STATUS[file.change_kind] - : "modified", + status: gitStatusFor(file), })), [files], ); - const initialExpandedPaths = useMemo(() => ancestorPaths(paths), [paths]); + const onSelectRef = useRef(onSelect); + onSelectRef.current = onSelect; + + const selectedPathRef = useRef(selectedPath); + selectedPathRef.current = selectedPath; + + const changedPathsRef = useRef>(changedPaths); + changedPathsRef.current = changedPaths; + + const pendingSelectedPathRef = useRef(null); const { model } = useFileTree({ paths, flattenEmptyDirectories: true, - initialExpandedPaths, + initialExpansion: "open", initialSelectedPaths: selectedPath ? [selectedPath] : undefined, gitStatus, icons: "standard", @@ -77,53 +114,60 @@ export function FileTreeSidebar({ search: true, fileTreeSearchMode: "hide-non-matches", onSelectionChange: (selected) => { - const first = selected[0]; - if (first) onSelect(first); + const selectedFile = lastSelectedFile(selected, changedPathsRef.current); + if (!selectedFile) return; + pendingSelectedPathRef.current = selectedFile; + onSelectRef.current(selectedFile); }, }); - // Keep the long-lived model in sync when the underlying files change - // (e.g. a Refresh fetched a new commit). + const didSyncModelRef = useRef(false); useEffect(() => { + if (!didSyncModelRef.current) { + didSyncModelRef.current = true; + return; + } model.resetPaths(paths); - }, [model, paths]); - - useEffect(() => { model.setGitStatus(gitStatus); - }, [model, gitStatus]); + pendingSelectedPathRef.current = null; + const currentSelectedPath = selectedPathRef.current; + syncSelection( + model, + model.getSelectedPaths(), + currentSelectedPath && changedPathsRef.current.has(currentSelectedPath) + ? currentSelectedPath + : null, + ); + }, [gitStatus, model, paths]); - // Drive selection from the URL hash (back/forward navigation, deep links). const selection = useFileTreeSelection(model); useEffect(() => { - if (!selectedPath) return; - if (selection.length === 1 && selection[0] === selectedPath) return; - const item = model.getItem(selectedPath); - if (item) item.select(); - }, [model, selectedPath, selection]); + const pendingSelectedPath = pendingSelectedPathRef.current; + if (pendingSelectedPath === selectedPath) { + pendingSelectedPathRef.current = null; + } + const nextSelectedPath = pendingSelectedPath ?? selectedPath; + syncSelection( + model, + selection, + nextSelectedPath && changedPaths.has(nextSelectedPath) ? nextSelectedPath : null, + ); + }, [changedPaths, model, selectedPath, selection]); const search = useFileTreeSearch(model); const handleSearchChange = (event: ChangeEvent) => search.setValue(event.target.value); - // `themeToTreeStyles` returns a Record of CSS custom - // properties (--trees-theme-*). React's CSSProperties is closed and - // doesn't accept arbitrary string keys, so apply them imperatively to a - // wrapper element — they cascade into the tree's shadow DOM as expected. - const themeStyles = useMemo(() => themeToTreeStyles(pierreDark), []); - const themeRef = useRef(null); - useLayoutEffect(() => { - const el = themeRef.current; - if (!el) return; - for (const [key, value] of Object.entries(themeStyles)) { - if (key.startsWith("--")) el.style.setProperty(key, value); - } - }, [themeStyles]); + const themeStyles = useMemo( + () => themeToTreeStyles(pierreDark) as TreeThemeStyle, + [], + ); return (