186 lines · 7.1 KB · TypeScript
RawDownload
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<DiffFileTreeEntry>;  /** 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<ReadonlyArray<GitStatusEntry>>(    () => entries.map((entry) => ({ path: entry.path, status: entry.status })),    [entries],  );  const filePathsRef = useRef<ReadonlySet<string>>(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<ReadonlyArray<string> | 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 (    <div className={cn("flex min-h-0 flex-1 flex-col bg-background", className)}>      <div        className="flex h-10 min-h-10 shrink-0 items-center gap-1 border-b border-border/60 bg-background px-2 text-xs text-muted-foreground in-data-[preview-panel-mode=inline]:mb-3 in-data-[preview-panel-mode=inline]:h-7 in-data-[preview-panel-mode=inline]:min-h-7 in-data-[preview-panel-mode=inline]:border-b-transparent"        data-surface-subheader      >        <span className="px-1 font-medium text-foreground">Files</span>        <span className="ml-auto tabular-nums">{entries.length}</span>        {headerAccessory}        {directoryPaths.length > 0 ? (          <Tooltip>            <TooltipTrigger              render={                <Button                  type="button"                  size="icon-xs"                  variant="ghost"                  aria-label={                    allDirectoriesExpanded ? "Collapse all folders" : "Expand all folders"                  }                  onClick={() =>                    setAllDirectoriesExpanded(model, directoryPaths, !allDirectoriesExpanded)                  }                />              }            >              {allDirectoriesExpanded ? (                <ChevronsDownUpIcon className="size-3.5" />              ) : (                <ChevronsUpDownIcon className="size-3.5" />              )}            </TooltipTrigger>            <TooltipPopup>              {allDirectoriesExpanded ? "Collapse all folders" : "Expand all folders"}            </TooltipPopup>          </Tooltip>        ) : null}      </div>      <FileTree        model={model}        aria-label={ariaLabel}        className="min-h-0 flex-1 overflow-hidden"        style={pierreTreeStyle(resolvedTheme)}      />      {footer}    </div>  );}