250 lines · 9.0 KB · TypeScript
RawDownload
import { type TurnId } from "@t3tools/contracts";import { memo, useCallback, useMemo, useState } from "react";import { type TurnDiffFileChange } from "../../types";import {  buildTurnDiffTree,  summarizeTurnDiffStats,  type TurnDiffTreeNode,} from "../../lib/turnDiffTree";import {  ChevronsDownUpIcon,  ChevronsUpDownIcon,  ChevronRightIcon,  FileDiffIcon,  FolderIcon,  FolderClosedIcon,} from "lucide-react";import { cn } from "~/lib/utils";import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel";import { PierreEntryIcon } from "./PierreEntryIcon";import { Button } from "../ui/button";import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; const EMPTY_DIRECTORY_OVERRIDES: Record<string, boolean> = {}; export const ChangedFilesCard = memo(function ChangedFilesCard(props: {  turnId: TurnId;  files: ReadonlyArray<TurnDiffFileChange>;  allDirectoriesExpanded: boolean;  resolvedTheme: "light" | "dark";  onToggleAllDirectories: () => void;  onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;}) {  const {    turnId,    files,    allDirectoriesExpanded,    resolvedTheme,    onToggleAllDirectories,    onOpenTurnDiff,  } = props;  const summaryStat = useMemo(() => summarizeTurnDiffStats(files), [files]);  const hasDirectories = files.some((file) => /[/\\]/.test(file.path));   return (    <div      className="@container/changed-files mt-4 rounded-lg bg-secondary dark:bg-input/20"      data-changed-files-state="tree"    >      <div        data-changed-files-header=""        className="sticky top-2 z-10 flex items-center justify-between gap-2 rounded-t-lg bg-secondary px-3 py-2 dark:bg-[color-mix(in_srgb,var(--input)_20%,var(--background))]"      >        <div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 text-xs font-medium text-foreground">          <span>            {files.length} changed file{files.length === 1 ? "" : "s"}          </span>          {hasNonZeroStat(summaryStat) && (            <DiffStatLabel              additions={summaryStat.additions}              deletions={summaryStat.deletions}              layout="inline"              className="text-xs leading-4"            />          )}        </div>        <div className="flex shrink-0 items-center gap-1">          {hasDirectories && (            <Tooltip>              <TooltipTrigger                render={                  <Button                    type="button"                    size="icon-xs"                    variant="ghost-muted"                    aria-label={                      allDirectoriesExpanded ? "Collapse all folders" : "Expand all folders"                    }                    data-scroll-anchor-ignore                    onClick={onToggleAllDirectories}                  />                }              >                {allDirectoriesExpanded ? (                  <ChevronsDownUpIcon className="size-3" />                ) : (                  <ChevronsUpDownIcon className="size-3" />                )}              </TooltipTrigger>              <TooltipPopup side="top">                {allDirectoriesExpanded ? "Collapse all folders" : "Expand all folders"}              </TooltipPopup>            </Tooltip>          )}          <Tooltip>            <TooltipTrigger              render={                <Button                  type="button"                  size="xs"                  variant="ghost-muted"                  aria-label="Open diff"                  onClick={() => onOpenTurnDiff(turnId, files[0]?.path)}                />              }            >              <FileDiffIcon className="size-3" />              <span className="hidden @[24rem]/changed-files:inline">Open diff</span>            </TooltipTrigger>            <TooltipPopup side="top">Open the full diff</TooltipPopup>          </Tooltip>        </div>      </div>      <ChangedFilesTree        key={`${turnId}:${allDirectoriesExpanded}`}        turnId={turnId}        files={files}        allDirectoriesExpanded={allDirectoriesExpanded}        resolvedTheme={resolvedTheme}        onOpenTurnDiff={onOpenTurnDiff}      />    </div>  );}); 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 hasDirectoryNodes = directoryPathsKey.length > 0;  const expansionStateKey = `${allDirectoriesExpanded ? "expanded" : "collapsed"}\u0000${directoryPathsKey}`;  const [directoryExpansionState, setDirectoryExpansionState] = useState<{    key: string;    overrides: Record<string, boolean>;  }>(() => ({    key: expansionStateKey,    overrides: {},  }));  const expandedDirectories =    directoryExpansionState.key === expansionStateKey      ? directoryExpansionState.overrides      : EMPTY_DIRECTORY_OVERRIDES;   const toggleDirectory = useCallback(    (pathValue: string) => {      setDirectoryExpansionState((current) => {        const nextOverrides = current.key === expansionStateKey ? current.overrides : {};        return {          key: expansionStateKey,          overrides: {            ...nextOverrides,            [pathValue]: !(nextOverrides[pathValue] ?? allDirectoriesExpanded),          },        };      });    },    [allDirectoriesExpanded, expansionStateKey],  );   const renderTreeNode = (node: TurnDiffTreeNode, depth: number) => {    const leftPadding = 8 + depth * 14;    if (node.kind === "directory") {      const isExpanded = expandedDirectories[node.path] ?? allDirectoriesExpanded;      return (        <div key={`dir:${node.path}`}>          <button            type="button"            data-scroll-anchor-ignore            aria-expanded={isExpanded}            className="group flex w-full items-center gap-2 rounded-md py-1.5 pr-2 text-left transition-colors hover:bg-accent/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background"            style={{ paddingLeft: `${leftPadding}px` }}            onClick={() => toggleDirectory(node.path)}          >            <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>{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-2 rounded-md py-1.5 pr-2 text-left transition-colors hover:bg-accent/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background"        style={{ paddingLeft: `${leftPadding}px` }}        onClick={() => onOpenTurnDiff(turnId, node.path)}      >        {hasDirectoryNodes || depth > 0 ? (          <span aria-hidden="true" className="size-3.5 shrink-0" />        ) : null}        <PierreEntryIcon          pathValue={node.path}          kind="file"          theme={resolvedTheme}          className="size-3.5 text-muted-foreground/70"        />        <span className="truncate font-mono text-xs text-foreground/85 group-hover:text-foreground">          {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="p-2">{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;}