import type { GitStatusEntry } from "@pierre/trees"; import { FileTree, useFileTree, useFileTreeSelector } from "@pierre/trees/react"; import { ChevronsDownUpIcon, ChevronsUpDownIcon } from "lucide-react"; import { useEffect, useRef, useState, type ReactNode } from "react"; import { useStableCallback } from "~/hooks/useStableCallback"; import { useTheme } from "~/hooks/useTheme"; import { cn } from "~/lib/utils"; 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, compareDiffFileTreeEntries, collectDirectoryPaths, diffFileTreeModel, diffFileTreePositions, type DiffFileTreeEntry, } from "./diffFileTree.logic"; export type { DiffFileTreeEntry } from "./diffFileTree.logic"; interface DiffFileTreeProps { readonly entries: ReadonlyArray; readonly onSelectFile: (path: string) => void; readonly selectedPath?: string | null; readonly revealRequestId?: number; readonly ariaLabel: string; readonly headerAccessory?: ReactNode; readonly footer?: ReactNode; readonly className?: string; } export function DiffFileTree({ entries, onSelectFile, selectedPath = null, revealRequestId = 0, ariaLabel, headerAccessory, footer, className, }: DiffFileTreeProps) { const { resolvedTheme } = useTheme(); const paths = entries.map((entry) => entry.path); const presented = diffFileTreeModel(paths); const modelPaths = presented.paths; const icons = presented.icons; const directoryPaths = collectDirectoryPaths(modelPaths); const positions = diffFileTreePositions(modelPaths); const [ordering] = useState(() => { let currentPositions: ReadonlyMap = new Map(); return { sort: compareDiffFileTreeEntries(() => currentPositions), update: (nextPositions: ReadonlyMap) => { currentPositions = nextPositions; }, }; }); const gitStatus: ReadonlyArray = entries.map((entry) => ({ path: presented.modelPath(entry.path), status: entry.status, })); const syncingSelectionRef = useRef(false); const handledRevealRef = useRef<{ path: string; revealRequestId: number; } | null>(null); const mountedPresentationRef = useRef | null>(null); // The tree model keeps the first selection handler it was given. const handleSelectionChange = useStableCallback((selectedPaths: ReadonlyArray) => { if (syncingSelectionRef.current) return; const modelPath = selectedPaths.at(-1); if (modelPath && modelPaths.includes(modelPath)) { onSelectFile(presented.selectionPath(modelPath)); } }); const { model } = useFileTree({ density: "compact", flattenEmptyDirectories: true, initialExpansion: "open", icons, onSelectionChange: handleSelectionChange, paths: [], search: false, sort: ordering.sort, unsafeCSS: PIERRE_TREE_UNSAFE_CSS, }); const allDirectoriesExpanded = useFileTreeSelector(model, (currentModel) => areAllDirectoriesExpanded(currentModel, directoryPaths), ); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- pushes the icon set into the imperative Pierre file tree model useEffect(() => { model.setIcons(icons); }, [icons, model]); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- applies path updates, ordering and git status to the imperative Pierre file tree model useEffect(() => { ordering.update(positions); const mounted = mountedPresentationRef.current; if (mounted?.paths === modelPaths) return; mountedPresentationRef.current = presented; if (mounted === null) { model.resetPaths(modelPaths); } else if (mounted.paths.every((path, index) => modelPaths[index] === path)) { const updates = buildDiffFileTreeUpdates(mounted.paths, modelPaths); if (updates.length > 0) model.batch(updates); } else { // A reset can turn a file path into a directory. Carry selection and focus // through real paths so paging keeps the same file without revealing it again. const focusedPath = model.getFocusedPath(); const nextFocusedPath = focusedPath === null ? null : presented.modelPath(mounted.selectionPath(focusedPath)); const selectedPaths = model .getSelectedPaths() .map((path) => presented.modelPath(mounted.selectionPath(path))) .filter((path) => path.endsWith("/") || modelPaths.includes(path)); const collapsedDirectories = directoryPaths.filter((path) => { const directory = model.getItem(path); return directory !== null && "isExpanded" in directory && !directory.isExpanded(); }); syncingSelectionRef.current = true; model.resetPaths(modelPaths); for (const path of model.getSelectedPaths()) model.getItem(path)?.deselect(); for (const path of selectedPaths) model.getItem(path)?.select(); queueMicrotask(() => { syncingSelectionRef.current = false; }); for (const path of collapsedDirectories) { const directory = model.getItem(path); if (directory !== null && "collapse" in directory) directory.collapse(); } if ( nextFocusedPath !== null && (modelPaths.includes(nextFocusedPath) || directoryPaths.includes(nextFocusedPath)) ) { model.focusPath(nextFocusedPath); } } model.setGitStatus(gitStatus); }, [directoryPaths, gitStatus, model, modelPaths, ordering, positions, presented]); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- expands ancestors, selects and scrolls to the requested file in the Pierre tree model useEffect(() => { if (selectedPath === null) { handledRevealRef.current = null; return; } const modelPath = presented.modelPath(selectedPath); const item = model.getItem(modelPath); if (item === null || item.isDirectory()) { 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 !== modelPath) model.getItem(path)?.deselect(); } let ancestor = ""; for (const segment of modelPath.split("/").slice(0, -1)) { ancestor += `${segment}/`; const directory = model.getItem(ancestor); if (directory !== null && "expand" in directory) directory.expand(); } item.select(); model.scrollToPath(modelPath, { offset: "nearest" }); queueMicrotask(() => { syncingSelectionRef.current = false; }); }, [model, presented, revealRequestId, selectedPath]); return (
Files {entries.length} {headerAccessory} {directoryPaths.length > 0 ? ( setAllDirectoriesExpanded(model, directoryPaths, !allDirectoriesExpanded) } /> } > {allDirectoriesExpanded ? ( ) : ( )} {allDirectoriesExpanded ? "Collapse all folders" : "Expand all folders"} ) : null}
{ if ( event.defaultPrevented || event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey ) { return; } // Pierre emits no change for its sole selected row; read before the click to avoid double reveals. const selected = model.getSelectedPaths(); const path = selected.length === 1 ? selected[0] : undefined; if (!path || !modelPaths.includes(path)) return; const clickedSelectedRow = event.nativeEvent .composedPath() .some( (node) => node instanceof HTMLElement && node.getAttribute("data-item-path") === path, ); if (clickedSelectedRow) onSelectFile(presented.selectionPath(path)); }} className="min-h-0 flex-1 overflow-hidden" style={pierreTreeStyle(resolvedTheme)} /> {footer}
); }