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 { Menu, MenuItem, MenuPopup } from "../ui/menu"; import { buildDiffFileTreeUpdates, compareDiffFileTreeEntries, collectDirectoryPaths, diffFileTreeModel, diffFileTreePositions, diffFileTreeReviewEntries, type DiffFileTreeEntry, } from "./diffFileTree.logic"; export type { DiffFileTreeEntry } from "./diffFileTree.logic"; interface DiffFileTreeProps { readonly entries: ReadonlyArray; /** All files affected by review actions, including folder descendants hidden by filters. */ readonly reviewEntries?: ReadonlyArray; /** Called with the file's path when the reader picks a file row. */ readonly onSelectFile: (path: string) => void; readonly selectedPath?: string | null; readonly revealRequestId?: number; readonly ariaLabel: string; readonly headerAccessory?: ReactNode; readonly footer?: ReactNode; readonly className?: string; readonly headerLabel?: string; readonly onReviewFiles?: ((paths: ReadonlyArray, reviewed: boolean) => void) | undefined; } export function DiffFileTree({ entries, reviewEntries = entries, onSelectFile, selectedPath = null, revealRequestId = 0, ariaLabel, headerAccessory, footer, className, headerLabel = "Files", onReviewFiles, }: 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 decorations = new Map(); for (const entry of entries) { const path = presented.modelPath(entry.path); const parts = path.split("/"); const paths = [ path, ...parts.slice(0, -1).map((_, index) => parts.slice(0, index + 1).join("/") + "/"), ]; for (const path of paths) { const previous = decorations.get(path) ?? { total: 0, reviewed: 0, unresolved: 0 }; decorations.set(path, { total: previous.total + 1, reviewed: previous.reviewed + Number(entry.reviewed === true), unresolved: previous.unresolved + (entry.unresolvedComments ?? 0), }); } } const decorationVersion = JSON.stringify([...decorations]); const renderRowDecoration = useStableCallback( ({ item }: { item: { kind: string; path: string } }) => { const state = decorations.get( item.kind === "directory" && !item.path.endsWith("/") ? `${item.path}/` : item.path, ); if (!state) return null; const count = state.unresolved; const reviewed = state.reviewed === state.total; if (!reviewed && count === 0) return null; return { text: `${reviewed ? "✓" : ""}${count > 0 ? ` ${count}` : ""}`.trim(), title: `${reviewed ? "Reviewed. " : ""}${count > 0 ? `${count} unresolved or pending comments` : ""}`, }; }, ); const { model } = useFileTree({ density: "compact", flattenEmptyDirectories: true, initialExpansion: "open", icons, onSelectionChange: handleSelectionChange, paths: [], search: false, sort: ordering.sort, unsafeCSS: `${PIERRE_TREE_UNSAFE_CSS} /* Preserve both the review check and count in Pierre's custom decoration lane. */ [data-item-section='decoration'] { min-width: max-content; } [data-item-section='decoration'] > span { flex: none; max-width: none; } `, composition: { contextMenu: { enabled: false, triggerMode: "right-click" }, }, renderRowDecoration, }); 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); } } }, [directoryPaths, 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(() => { model.setGitStatus(gitStatus); }, [gitStatus, model]); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Refresh the imperative tree decorations after review state changes. useEffect(() => { // Pierre ignores unchanged git statuses, so reviewed/comment-only updates need a render // of the existing tree. This preserves its scroll, selection, and folder expansion. if (model.getFileTreeContainer()) model.render({}); // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Review marks change the row decoration without changing Git status or tree paths. }, [decorationVersion, model]); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Synchronize external file selection into the imperative tree and its scroll position. 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 (
{headerLabel} {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 min-w-0 flex-1 overflow-hidden" style={pierreTreeStyle(resolvedTheme)} {...(onReviewFiles ? { renderContextMenu: (item, context) => { const matches = diffFileTreeReviewEntries( reviewEntries, model.getSelectedPaths(), item.path, ); if (matches.length === 0) return null; const paths = matches.map((entry) => entry.path); const canMarkReviewed = matches.some((entry) => !entry.reviewed); const canMarkUnreviewed = matches.some((entry) => entry.reviewed); return ( { if (!open) context.close(); }} > {canMarkReviewed ? ( { onReviewFiles(paths, true); context.close(); }} > Mark as reviewed ) : null} {canMarkUnreviewed ? ( { onReviewFiles(paths, false); context.close(); }} > Mark as unreviewed ) : null} ); }, } : {})} /> {footer}
); }