136 lines · 5.1 KB · TypeScript
RawDownload
import { type TurnId } from "@t3tools/contracts";import { memo, useCallback, useEffect, useMemo, useState } from "react";import { type TurnDiffFileChange } from "../../types";import { buildTurnDiffTree, type TurnDiffTreeNode } from "../../lib/turnDiffTree";import { ChevronRightIcon, FolderIcon, FolderClosedIcon } from "lucide-react";import { cn } from "~/lib/utils";import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel";import { VscodeEntryIcon } from "./VscodeEntryIcon"; export const ChangedFilesTree = memo(function ChangedFilesTree(props: {  turnId: TurnId;  files: ReadonlyArray<TurnDiffFileChange>;  allDirectoriesExpanded: boolean;  resolvedTheme: "light" | "dark";  onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;}) {  const { files, allDirectoriesExpanded, onOpenTurnDiff, resolvedTheme, turnId } = props;  const treeNodes = useMemo(() => buildTurnDiffTree(files), [files]);  const directoryPathsKey = useMemo(    () => collectDirectoryPaths(treeNodes).join("\u0000"),    [treeNodes],  );  const allDirectoryExpansionState = useMemo(    () =>      buildDirectoryExpansionState(        directoryPathsKey ? directoryPathsKey.split("\u0000") : [],        allDirectoriesExpanded,      ),    [allDirectoriesExpanded, directoryPathsKey],  );  const [expandedDirectories, setExpandedDirectories] = useState<Record<string, boolean>>(() =>    buildDirectoryExpansionState(directoryPathsKey ? directoryPathsKey.split("\u0000") : [], true),  );  useEffect(() => {    setExpandedDirectories(allDirectoryExpansionState);  }, [allDirectoryExpansionState]);   const toggleDirectory = useCallback((pathValue: string, fallbackExpanded: boolean) => {    setExpandedDirectories((current) => ({      ...current,      [pathValue]: !(current[pathValue] ?? fallbackExpanded),    }));  }, []);   const renderTreeNode = (node: TurnDiffTreeNode, depth: number) => {    const leftPadding = 8 + depth * 14;    if (node.kind === "directory") {      const isExpanded = expandedDirectories[node.path] ?? depth === 0;      return (        <div key={`dir:${node.path}`}>          <button            type="button"            className="group flex w-full items-center gap-1.5 rounded-md py-1 pr-2 text-left hover:bg-background/80"            style={{ paddingLeft: `${leftPadding}px` }}            onClick={() => toggleDirectory(node.path, depth === 0)}          >            <ChevronRightIcon              aria-hidden="true"              className={cn(                "size-3.5 shrink-0 text-muted-foreground/70 transition-transform group-hover:text-foreground/80",                isExpanded && "rotate-90",              )}            />            {isExpanded ? (              <FolderIcon className="size-3.5 shrink-0 text-muted-foreground/75" />            ) : (              <FolderClosedIcon className="size-3.5 shrink-0 text-muted-foreground/75" />            )}            <span className="truncate font-mono text-[11px] text-muted-foreground/90 group-hover:text-foreground/90">              {node.name}            </span>            {hasNonZeroStat(node.stat) && (              <span className="ml-auto shrink-0 font-mono text-[10px] tabular-nums">                <DiffStatLabel additions={node.stat.additions} deletions={node.stat.deletions} />              </span>            )}          </button>          {isExpanded && (            <div className="space-y-0.5">              {node.children.map((childNode) => renderTreeNode(childNode, depth + 1))}            </div>          )}        </div>      );    }     return (      <button        key={`file:${node.path}`}        type="button"        className="group flex w-full items-center gap-1.5 rounded-md py-1 pr-2 text-left hover:bg-background/80"        style={{ paddingLeft: `${leftPadding}px` }}        onClick={() => onOpenTurnDiff(turnId, node.path)}      >        <span aria-hidden="true" className="size-3.5 shrink-0" />        <VscodeEntryIcon          pathValue={node.path}          kind="file"          theme={resolvedTheme}          className="size-3.5 text-muted-foreground/70"        />        <span className="truncate font-mono text-[11px] text-muted-foreground/80 group-hover:text-foreground/90">          {node.name}        </span>        {node.stat && (          <span className="ml-auto shrink-0 font-mono text-[10px] tabular-nums">            <DiffStatLabel additions={node.stat.additions} deletions={node.stat.deletions} />          </span>        )}      </button>    );  };   return <div className="space-y-0.5">{treeNodes.map((node) => renderTreeNode(node, 0))}</div>;}); function collectDirectoryPaths(nodes: ReadonlyArray<TurnDiffTreeNode>): string[] {  const paths: string[] = [];  for (const node of nodes) {    if (node.kind !== "directory") continue;    paths.push(node.path);    paths.push(...collectDirectoryPaths(node.children));  }  return paths;} function buildDirectoryExpansionState(  directoryPaths: ReadonlyArray<string>,  expanded: boolean,): Record<string, boolean> {  const expandedState: Record<string, boolean> = {};  for (const directoryPath of directoryPaths) {    expandedState[directoryPath] = expanded;  }  return expandedState;}