609 lines · 23.4 KB · TypeScript
RawDownload
import { parsePatchFiles } from "@pierre/diffs";import { FileDiff, type FileDiffMetadata, Virtualizer } from "@pierre/diffs/react";import { useQuery } from "@tanstack/react-query";import { useNavigate, useParams, useSearch } from "@tanstack/react-router";import { ThreadId, type TurnId } from "@t3tools/contracts";import { ChevronLeftIcon, ChevronRightIcon, Columns2Icon, Rows3Icon } from "lucide-react";import {  type WheelEvent as ReactWheelEvent,  useCallback,  useEffect,  useMemo,  useRef,  useState,} from "react";import { openInPreferredEditor } from "../editorPreferences";import { gitBranchesQueryOptions } from "~/lib/gitReactQuery";import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery";import { cn } from "~/lib/utils";import { readNativeApi } from "../nativeApi";import { resolvePathLinkTarget } from "../terminal-links";import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";import { useTheme } from "../hooks/useTheme";import { buildPatchCacheKey } from "../lib/diffRendering";import { resolveDiffThemeName } from "../lib/diffRendering";import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";import { useStore } from "../store";import { useAppSettings } from "../appSettings";import { formatShortTimestamp } from "../timestampFormat";import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell";import { ToggleGroup, Toggle } from "./ui/toggle-group"; type DiffRenderMode = "stacked" | "split";type DiffThemeType = "light" | "dark"; const DIFF_PANEL_UNSAFE_CSS = `[data-diffs-header],[data-diff],[data-file],[data-error-wrapper],[data-virtualizer-buffer] {  --diffs-bg: color-mix(in srgb, var(--card) 90%, var(--background)) !important;  --diffs-light-bg: color-mix(in srgb, var(--card) 90%, var(--background)) !important;  --diffs-dark-bg: color-mix(in srgb, var(--card) 90%, var(--background)) !important;  --diffs-token-light-bg: transparent;  --diffs-token-dark-bg: transparent;   --diffs-bg-context-override: color-mix(in srgb, var(--background) 97%, var(--foreground));  --diffs-bg-hover-override: color-mix(in srgb, var(--background) 94%, var(--foreground));  --diffs-bg-separator-override: color-mix(in srgb, var(--background) 95%, var(--foreground));  --diffs-bg-buffer-override: color-mix(in srgb, var(--background) 90%, var(--foreground));   --diffs-bg-addition-override: color-mix(in srgb, var(--background) 92%, var(--success));  --diffs-bg-addition-number-override: color-mix(in srgb, var(--background) 88%, var(--success));  --diffs-bg-addition-hover-override: color-mix(in srgb, var(--background) 85%, var(--success));  --diffs-bg-addition-emphasis-override: color-mix(in srgb, var(--background) 80%, var(--success));   --diffs-bg-deletion-override: color-mix(in srgb, var(--background) 92%, var(--destructive));  --diffs-bg-deletion-number-override: color-mix(in srgb, var(--background) 88%, var(--destructive));  --diffs-bg-deletion-hover-override: color-mix(in srgb, var(--background) 85%, var(--destructive));  --diffs-bg-deletion-emphasis-override: color-mix(    in srgb,    var(--background) 80%,    var(--destructive)  );   background-color: var(--diffs-bg) !important;} [data-file-info] {  background-color: color-mix(in srgb, var(--card) 94%, var(--foreground)) !important;  border-block-color: var(--border) !important;  color: var(--foreground) !important;} [data-diffs-header] {  position: sticky !important;  top: 0;  z-index: 4;  background-color: color-mix(in srgb, var(--card) 94%, var(--foreground)) !important;  border-bottom: 1px solid var(--border) !important;} [data-title] {  cursor: pointer;  transition:    color 120ms ease,    text-decoration-color 120ms ease;  text-decoration: underline;  text-decoration-color: transparent;  text-underline-offset: 2px;} [data-title]:hover {  color: color-mix(in srgb, var(--foreground) 84%, var(--primary)) !important;  text-decoration-color: currentColor;}`; type RenderablePatch =  | {      kind: "files";      files: FileDiffMetadata[];    }  | {      kind: "raw";      text: string;      reason: string;    }; function getRenderablePatch(  patch: string | undefined,  cacheScope = "diff-panel",): RenderablePatch | null {  if (!patch) return null;  const normalizedPatch = patch.trim();  if (normalizedPatch.length === 0) return null;   try {    const parsedPatches = parsePatchFiles(      normalizedPatch,      buildPatchCacheKey(normalizedPatch, cacheScope),    );    const files = parsedPatches.flatMap((parsedPatch) => parsedPatch.files);    if (files.length > 0) {      return { kind: "files", files };    }     return {      kind: "raw",      text: normalizedPatch,      reason: "Unsupported diff format. Showing raw patch.",    };  } catch {    return {      kind: "raw",      text: normalizedPatch,      reason: "Failed to parse patch. Showing raw patch.",    };  }} function resolveFileDiffPath(fileDiff: FileDiffMetadata): string {  const raw = fileDiff.name ?? fileDiff.prevName ?? "";  if (raw.startsWith("a/") || raw.startsWith("b/")) {    return raw.slice(2);  }  return raw;} function buildFileDiffRenderKey(fileDiff: FileDiffMetadata): string {  return fileDiff.cacheKey ?? `${fileDiff.prevName ?? "none"}:${fileDiff.name}`;} interface DiffPanelProps {  mode?: DiffPanelMode;} export { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {  const navigate = useNavigate();  const { resolvedTheme } = useTheme();  const { settings } = useAppSettings();  const [diffRenderMode, setDiffRenderMode] = useState<DiffRenderMode>("stacked");  const patchViewportRef = useRef<HTMLDivElement>(null);  const turnStripRef = useRef<HTMLDivElement>(null);  const [canScrollTurnStripLeft, setCanScrollTurnStripLeft] = useState(false);  const [canScrollTurnStripRight, setCanScrollTurnStripRight] = useState(false);  const routeThreadId = useParams({    strict: false,    select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null),  });  const diffSearch = useSearch({ strict: false, select: (search) => parseDiffRouteSearch(search) });  const activeThreadId = routeThreadId;  const activeThread = useStore((store) =>    activeThreadId ? store.threads.find((thread) => thread.id === activeThreadId) : undefined,  );  const activeProjectId = activeThread?.projectId ?? null;  const activeProject = useStore((store) =>    activeProjectId ? store.projects.find((project) => project.id === activeProjectId) : undefined,  );  const activeCwd = activeThread?.worktreePath ?? activeProject?.cwd;  const gitBranchesQuery = useQuery(gitBranchesQueryOptions(activeCwd ?? null));  const isGitRepo = gitBranchesQuery.data?.isRepo ?? true;  const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =    useTurnDiffSummaries(activeThread);  const orderedTurnDiffSummaries = useMemo(    () =>      [...turnDiffSummaries].toSorted((left, right) => {        const leftTurnCount =          left.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[left.turnId] ?? 0;        const rightTurnCount =          right.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[right.turnId] ?? 0;        if (leftTurnCount !== rightTurnCount) {          return rightTurnCount - leftTurnCount;        }        return right.completedAt.localeCompare(left.completedAt);      }),    [inferredCheckpointTurnCountByTurnId, turnDiffSummaries],  );   const selectedTurnId = diffSearch.diffTurnId ?? null;  const selectedFilePath = selectedTurnId !== null ? (diffSearch.diffFilePath ?? null) : null;  const selectedTurn =    selectedTurnId === null      ? undefined      : (orderedTurnDiffSummaries.find((summary) => summary.turnId === selectedTurnId) ??        orderedTurnDiffSummaries[0]);  const selectedCheckpointTurnCount =    selectedTurn &&    (selectedTurn.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[selectedTurn.turnId]);  const selectedCheckpointRange = useMemo(    () =>      typeof selectedCheckpointTurnCount === "number"        ? {            fromTurnCount: Math.max(0, selectedCheckpointTurnCount - 1),            toTurnCount: selectedCheckpointTurnCount,          }        : null,    [selectedCheckpointTurnCount],  );  const conversationCheckpointTurnCount = useMemo(() => {    const turnCounts = orderedTurnDiffSummaries      .map(        (summary) =>          summary.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[summary.turnId],      )      .filter((value): value is number => typeof value === "number");    if (turnCounts.length === 0) {      return undefined;    }    const latest = Math.max(...turnCounts);    return latest > 0 ? latest : undefined;  }, [inferredCheckpointTurnCountByTurnId, orderedTurnDiffSummaries]);  const conversationCheckpointRange = useMemo(    () =>      !selectedTurn && typeof conversationCheckpointTurnCount === "number"        ? {            fromTurnCount: 0,            toTurnCount: conversationCheckpointTurnCount,          }        : null,    [conversationCheckpointTurnCount, selectedTurn],  );  const activeCheckpointRange = selectedTurn    ? selectedCheckpointRange    : conversationCheckpointRange;  const conversationCacheScope = useMemo(() => {    if (selectedTurn || orderedTurnDiffSummaries.length === 0) {      return null;    }    return `conversation:${orderedTurnDiffSummaries.map((summary) => summary.turnId).join(",")}`;  }, [orderedTurnDiffSummaries, selectedTurn]);  const activeCheckpointDiffQuery = useQuery(    checkpointDiffQueryOptions({      threadId: activeThreadId,      fromTurnCount: activeCheckpointRange?.fromTurnCount ?? null,      toTurnCount: activeCheckpointRange?.toTurnCount ?? null,      cacheScope: selectedTurn ? `turn:${selectedTurn.turnId}` : conversationCacheScope,      enabled: isGitRepo,    }),  );  const selectedTurnCheckpointDiff = selectedTurn    ? activeCheckpointDiffQuery.data?.diff    : undefined;  const conversationCheckpointDiff = selectedTurn    ? undefined    : activeCheckpointDiffQuery.data?.diff;  const isLoadingCheckpointDiff = activeCheckpointDiffQuery.isLoading;  const checkpointDiffError =    activeCheckpointDiffQuery.error instanceof Error      ? activeCheckpointDiffQuery.error.message      : activeCheckpointDiffQuery.error        ? "Failed to load checkpoint diff."        : null;   const selectedPatch = selectedTurn ? selectedTurnCheckpointDiff : conversationCheckpointDiff;  const hasResolvedPatch = typeof selectedPatch === "string";  const hasNoNetChanges = hasResolvedPatch && selectedPatch.trim().length === 0;  const renderablePatch = useMemo(    () => getRenderablePatch(selectedPatch, `diff-panel:${resolvedTheme}`),    [resolvedTheme, selectedPatch],  );  const renderableFiles = useMemo(() => {    if (!renderablePatch || renderablePatch.kind !== "files") {      return [];    }    return renderablePatch.files.toSorted((left, right) =>      resolveFileDiffPath(left).localeCompare(resolveFileDiffPath(right), undefined, {        numeric: true,        sensitivity: "base",      }),    );  }, [renderablePatch]);   useEffect(() => {    if (!selectedFilePath || !patchViewportRef.current) {      return;    }    const target = Array.from(      patchViewportRef.current.querySelectorAll<HTMLElement>("[data-diff-file-path]"),    ).find((element) => element.dataset.diffFilePath === selectedFilePath);    target?.scrollIntoView({ block: "nearest" });  }, [selectedFilePath, renderableFiles]);   const openDiffFileInEditor = useCallback(    (filePath: string) => {      const api = readNativeApi();      if (!api) return;      const targetPath = activeCwd ? resolvePathLinkTarget(filePath, activeCwd) : filePath;      void openInPreferredEditor(api, targetPath).catch((error) => {        console.warn("Failed to open diff file in editor.", error);      });    },    [activeCwd],  );   const selectTurn = (turnId: TurnId) => {    if (!activeThread) return;    void navigate({      to: "/$threadId",      params: { threadId: activeThread.id },      search: (previous) => {        const rest = stripDiffSearchParams(previous);        return { ...rest, diff: "1", diffTurnId: turnId };      },    });  };  const selectWholeConversation = () => {    if (!activeThread) return;    void navigate({      to: "/$threadId",      params: { threadId: activeThread.id },      search: (previous) => {        const rest = stripDiffSearchParams(previous);        return { ...rest, diff: "1" };      },    });  };  const updateTurnStripScrollState = useCallback(() => {    const element = turnStripRef.current;    if (!element) {      setCanScrollTurnStripLeft(false);      setCanScrollTurnStripRight(false);      return;    }     const maxScrollLeft = Math.max(0, element.scrollWidth - element.clientWidth);    setCanScrollTurnStripLeft(element.scrollLeft > 4);    setCanScrollTurnStripRight(element.scrollLeft < maxScrollLeft - 4);  }, []);  const scrollTurnStripBy = useCallback((offset: number) => {    const element = turnStripRef.current;    if (!element) return;    element.scrollBy({ left: offset, behavior: "smooth" });  }, []);  const onTurnStripWheel = useCallback((event: ReactWheelEvent<HTMLDivElement>) => {    const element = turnStripRef.current;    if (!element) return;    if (element.scrollWidth <= element.clientWidth + 1) return;    if (Math.abs(event.deltaY) <= Math.abs(event.deltaX)) return;     event.preventDefault();    element.scrollBy({ left: event.deltaY, behavior: "auto" });  }, []);   useEffect(() => {    const element = turnStripRef.current;    if (!element) return;     const frameId = window.requestAnimationFrame(() => updateTurnStripScrollState());    const onScroll = () => updateTurnStripScrollState();     element.addEventListener("scroll", onScroll, { passive: true });     const resizeObserver = new ResizeObserver(() => updateTurnStripScrollState());    resizeObserver.observe(element);     return () => {      window.cancelAnimationFrame(frameId);      element.removeEventListener("scroll", onScroll);      resizeObserver.disconnect();    };  }, [updateTurnStripScrollState]);   useEffect(() => {    const frameId = window.requestAnimationFrame(() => updateTurnStripScrollState());    return () => {      window.cancelAnimationFrame(frameId);    };  }, [orderedTurnDiffSummaries, selectedTurnId, updateTurnStripScrollState]);   useEffect(() => {    const element = turnStripRef.current;    if (!element) return;     const selectedChip = element.querySelector<HTMLElement>("[data-turn-chip-selected='true']");    selectedChip?.scrollIntoView({ block: "nearest", inline: "nearest", behavior: "smooth" });  }, [selectedTurn?.turnId, selectedTurnId]);   const headerRow = (    <>      <div className="relative min-w-0 flex-1 [-webkit-app-region:no-drag]">        {canScrollTurnStripLeft && (          <div className="pointer-events-none absolute inset-y-0 left-8 z-10 w-7 bg-linear-to-r from-card to-transparent" />        )}        {canScrollTurnStripRight && (          <div className="pointer-events-none absolute inset-y-0 right-8 z-10 w-7 bg-linear-to-l from-card to-transparent" />        )}        <button          type="button"          className={cn(            "absolute left-0 top-1/2 z-20 inline-flex size-6 -translate-y-1/2 items-center justify-center rounded-md border bg-background/90 text-muted-foreground transition-colors",            canScrollTurnStripLeft              ? "border-border/70 hover:border-border hover:text-foreground"              : "cursor-not-allowed border-border/40 text-muted-foreground/40",          )}          onClick={() => scrollTurnStripBy(-180)}          disabled={!canScrollTurnStripLeft}          aria-label="Scroll turn list left"        >          <ChevronLeftIcon className="size-3.5" />        </button>        <button          type="button"          className={cn(            "absolute right-0 top-1/2 z-20 inline-flex size-6 -translate-y-1/2 items-center justify-center rounded-md border bg-background/90 text-muted-foreground transition-colors",            canScrollTurnStripRight              ? "border-border/70 hover:border-border hover:text-foreground"              : "cursor-not-allowed border-border/40 text-muted-foreground/40",          )}          onClick={() => scrollTurnStripBy(180)}          disabled={!canScrollTurnStripRight}          aria-label="Scroll turn list right"        >          <ChevronRightIcon className="size-3.5" />        </button>        <div          ref={turnStripRef}          className="turn-chip-strip flex gap-1 overflow-x-auto px-8 py-0.5"          onWheel={onTurnStripWheel}        >          <button            type="button"            className="shrink-0 rounded-md"            onClick={selectWholeConversation}            data-turn-chip-selected={selectedTurnId === null}          >            <div              className={cn(                "rounded-md border px-2 py-1 text-left transition-colors",                selectedTurnId === null                  ? "border-border bg-accent text-accent-foreground"                  : "border-border/70 bg-background/70 text-muted-foreground/80 hover:border-border hover:text-foreground/80",              )}            >              <div className="text-[10px] leading-tight font-medium">All turns</div>            </div>          </button>          {orderedTurnDiffSummaries.map((summary) => (            <button              key={summary.turnId}              type="button"              className="shrink-0 rounded-md"              onClick={() => selectTurn(summary.turnId)}              title={summary.turnId}              data-turn-chip-selected={summary.turnId === selectedTurn?.turnId}            >              <div                className={cn(                  "rounded-md border px-2 py-1 text-left transition-colors",                  summary.turnId === selectedTurn?.turnId                    ? "border-border bg-accent text-accent-foreground"                    : "border-border/70 bg-background/70 text-muted-foreground/80 hover:border-border hover:text-foreground/80",                )}              >                <div className="flex items-center gap-1">                  <span className="text-[10px] leading-tight font-medium">                    Turn{" "}                    {summary.checkpointTurnCount ??                      inferredCheckpointTurnCountByTurnId[summary.turnId] ??                      "?"}                  </span>                  <span className="text-[9px] leading-tight opacity-70">                    {formatShortTimestamp(summary.completedAt, settings.timestampFormat)}                  </span>                </div>              </div>            </button>          ))}        </div>      </div>      <ToggleGroup        className="shrink-0 [-webkit-app-region:no-drag]"        variant="outline"        size="xs"        value={[diffRenderMode]}        onValueChange={(value) => {          const next = value[0];          if (next === "stacked" || next === "split") {

Lines 1–500 of 609.