import type { GitStatusEntry } from "@pierre/trees"; import { FileTree, useFileTree, useFileTreeSelector } from "@pierre/trees/react"; import { ChevronsDownUpIcon, ChevronsUpDownIcon } from "lucide-react"; import { useEffect, useMemo, useRef, type ReactNode } from "react"; import { useTheme } from "~/hooks/useTheme"; import { cn } from "~/lib/utils"; import { T3_PIERRE_ICONS } from "~/pierre-icons"; import { PIERRE_TREE_UNSAFE_CSS, pierreTreeStyle } from "~/pierre-tree-theme"; import { areAllDirectoriesExpanded, setAllDirectoriesExpanded } from "../files/fileTreeExpansion"; import { Button } from "../ui/button"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { buildDiffFileTreeUpdates, collectDirectoryPaths, type DiffFileTreeEntry, } from "./diffFileTree.logic"; export type { DiffFileTreeEntry } from "./diffFileTree.logic"; interface DiffFileTreeProps { readonly entries: ReadonlyArray; /** Called with the file's path when the reader picks a file row. */ readonly onSelectFile: (path: string) => void; /** * The file the diff is currently showing, kept selected in the tree. Bump `revealRequestId` to * scroll the tree to the same path again. */ readonly selectedPath?: string | null; readonly revealRequestId?: number; readonly ariaLabel: string; /** Right-aligned content in the header row, after the file count. */ readonly headerAccessory?: ReactNode; /** Rendered under the tree, for a host that still has files to fetch. */ readonly footer?: ReactNode; readonly className?: string; } /** * A directory tree of the files in a diff. Every directory starts open: a diff is a short list * compared to a workspace, and the reader came for the files, not the folders. */ export function DiffFileTree({ entries, onSelectFile, selectedPath = null, revealRequestId = 0, ariaLabel, headerAccessory, footer, className, }: DiffFileTreeProps) { const { resolvedTheme } = useTheme(); const paths = useMemo(() => entries.map((entry) => entry.path), [entries]); const directoryPaths = useMemo(() => collectDirectoryPaths(paths), [paths]); const gitStatus = useMemo>( () => entries.map((entry) => ({ path: entry.path, status: entry.status })), [entries], ); const filePathsRef = useRef>(new Set(paths)); const onSelectFileRef = useRef(onSelectFile); // Selection driven by `selectedPath` below is an echo of a file already on screen, not a // request to scroll to it again. const syncingSelectionRef = useRef(false); const handledRevealRef = useRef<{ path: string; revealRequestId: number } | null>(null); const mountedPathsRef = useRef | null>(null); useEffect(() => { filePathsRef.current = new Set(paths); onSelectFileRef.current = onSelectFile; }, [onSelectFile, paths]); const { model } = useFileTree({ density: "compact", flattenEmptyDirectories: true, initialExpansion: "open", icons: T3_PIERRE_ICONS, onSelectionChange: (selectedPaths) => { if (syncingSelectionRef.current) return; const path = selectedPaths.at(-1)?.replace(/\/$/, ""); if (path && filePathsRef.current.has(path)) onSelectFileRef.current(path); }, paths: [], search: false, unsafeCSS: PIERRE_TREE_UNSAFE_CSS, }); const allDirectoriesExpanded = useFileTreeSelector(model, (currentModel) => areAllDirectoriesExpanded(currentModel, directoryPaths), ); useEffect(() => { const mountedPaths = mountedPathsRef.current; if (mountedPaths === paths) return; mountedPathsRef.current = paths; if (mountedPaths === null) { model.resetPaths(paths); } else { const updates = buildDiffFileTreeUpdates(mountedPaths, paths); if (updates.length > 0) model.batch(updates); } model.setGitStatus(gitStatus); }, [gitStatus, model, paths]); useEffect(() => { if (selectedPath === null) { handledRevealRef.current = null; return; } // A path list that changes under an already-revealed file (a refresh, a later slice) must // not pull the tree back to it over whatever the reader has picked since. const item = model.getItem(selectedPath); if (item === null || item.isDirectory()) { // A file that left the diff has to be revealed again when it comes back. handledRevealRef.current = null; return; } const handled = handledRevealRef.current; if (handled?.path === selectedPath && handled.revealRequestId === revealRequestId) return; handledRevealRef.current = { path: selectedPath, revealRequestId }; syncingSelectionRef.current = true; for (const path of model.getSelectedPaths()) { if (path !== selectedPath) model.getItem(path)?.deselect(); } let ancestor = ""; for (const segment of selectedPath.split("/").slice(0, -1)) { ancestor += `${segment}/`; const directory = model.getItem(ancestor); if (directory !== null && "expand" in directory) directory.expand(); } item.select(); model.scrollToPath(selectedPath, { offset: "nearest" }); queueMicrotask(() => { syncingSelectionRef.current = false; }); // `paths` is a dependency so a file that arrives after it was asked for is still revealed. }, [model, paths, revealRequestId, selectedPath]); return (
Files {entries.length} {headerAccessory} {directoryPaths.length > 0 ? ( setAllDirectoriesExpanded(model, directoryPaths, !allDirectoriesExpanded) } /> } > {allDirectoriesExpanded ? ( ) : ( )} {allDirectoriesExpanded ? "Collapse all folders" : "Expand all folders"} ) : null}
{footer}
); }