Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
292 changes: 85 additions & 207 deletions apps/web/src/components/DiffPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,8 @@
import { parsePatchFiles } from "@pierre/diffs";
import { FileDiff, type FileDiffMetadata, Virtualizer } from "@pierre/diffs/react";
import { useQuery } from "@tanstack/react-query";
import { type TurnId } from "@okcode/contracts";
import {
ChevronLeftIcon,
ChevronRightIcon,
Columns2Icon,
Rows3Icon,
TextWrapIcon,
XIcon,
} from "lucide-react";
import {
type WheelEvent as ReactWheelEvent,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { Columns2Icon, Rows3Icon, TextWrapIcon, XIcon } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";

import { openInPreferredEditor } from "../editorPreferences";
import { useDiffViewerStore } from "../diffViewerStore";
Expand All @@ -28,7 +13,6 @@ import { buildPatchCacheKey, resolveDiffThemeName } from "../lib/diffRendering";
import { cn } from "../lib/utils";
import { readNativeApi } from "../nativeApi";
import { useStore } from "../store";
import { formatShortTimestamp } from "../timestampFormat";
import { resolvePathLinkTarget } from "../terminal-links";
import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell";
import { Button } from "./ui/button";
Expand Down Expand Up @@ -149,6 +133,33 @@ function buildFileDiffRenderKey(fileDiff: FileDiffMetadata): string {
return fileDiff.cacheKey ?? `${fileDiff.prevName ?? "none"}:${fileDiff.name}`;
}

type FileDiffCategory = "all" | "added" | "modified" | "deleted" | "renamed";

const CATEGORY_ORDER: FileDiffCategory[] = ["all", "added", "modified", "deleted", "renamed"];

const CATEGORY_LABELS: Record<FileDiffCategory, string> = {
all: "All",
added: "Added",
modified: "Modified",
deleted: "Deleted",
renamed: "Renamed",
};

function categorizeFileDiff(fileDiff: FileDiffMetadata): Exclude<FileDiffCategory, "all"> {
switch (fileDiff.type) {
case "new":
return "added";
case "deleted":
return "deleted";
case "rename-pure":
case "rename-changed":
return "renamed";
case "change":
default:
return "modified";
}
}

interface DiffPanelProps {
mode?: DiffPanelMode;
}
Expand All @@ -157,17 +168,13 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
const { resolvedTheme } = useTheme();
const [diffRenderMode, setDiffRenderMode] = useState<DiffRenderMode>("stacked");
const [diffWordWrap, setDiffWordWrap] = useState(false);
const [selectedCategory, setSelectedCategory] = useState<FileDiffCategory>("all");
const patchViewportRef = useRef<HTMLDivElement>(null);
const turnStripRef = useRef<HTMLDivElement>(null);
const previousDiffOpenRef = useRef(false);
const [canScrollTurnStripLeft, setCanScrollTurnStripLeft] = useState(false);
const [canScrollTurnStripRight, setCanScrollTurnStripRight] = useState(false);

const diffViewerThreadId = useDiffViewerStore((state) => state.threadId);
const diffOpen = useDiffViewerStore((state) => state.isOpen);
const selectedTurnId = useDiffViewerStore((state) => state.selectedTurnId);
const selectedFilePath = useDiffViewerStore((state) => state.selectedFilePath);
const setSelectedTurn = useDiffViewerStore((state) => state.setSelectedTurn);
const closeDiffViewer = useDiffViewerStore((state) => state.close);

const activeThread = useStore((store) =>
Expand Down Expand Up @@ -198,24 +205,6 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
[inferredCheckpointTurnCountByTurnId, turnDiffSummaries],
);

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(
Expand All @@ -229,31 +218,28 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
const latest = Math.max(...turnCounts);
return latest > 0 ? latest : undefined;
}, [inferredCheckpointTurnCountByTurnId, orderedTurnDiffSummaries]);
const conversationCheckpointRange = useMemo(
const activeCheckpointRange = useMemo(
() =>
!selectedTurn && typeof conversationCheckpointTurnCount === "number"
typeof conversationCheckpointTurnCount === "number"
? {
fromTurnCount: 0,
toTurnCount: conversationCheckpointTurnCount,
}
: null,
[conversationCheckpointTurnCount, selectedTurn],
[conversationCheckpointTurnCount],
);
const activeCheckpointRange = selectedTurn
? selectedCheckpointRange
: conversationCheckpointRange;
const conversationCacheScope = useMemo(() => {
if (selectedTurn || orderedTurnDiffSummaries.length === 0) {
if (orderedTurnDiffSummaries.length === 0) {
return null;
}
return `conversation:${orderedTurnDiffSummaries.map((summary) => summary.turnId).join(",")}`;
}, [orderedTurnDiffSummaries, selectedTurn]);
}, [orderedTurnDiffSummaries]);
const activeCheckpointDiffQuery = useQuery(
checkpointDiffQueryOptions({
threadId: diffViewerThreadId,
fromTurnCount: activeCheckpointRange?.fromTurnCount ?? null,
toTurnCount: activeCheckpointRange?.toTurnCount ?? null,
cacheScope: selectedTurn ? `turn:${selectedTurn.turnId}` : conversationCacheScope,
cacheScope: conversationCacheScope,
enabled: diffOpen,
}),
);
Expand Down Expand Up @@ -284,9 +270,29 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
);
}, [renderablePatch]);

const categoryCounts = useMemo(() => {
const counts: Record<Exclude<FileDiffCategory, "all">, number> = {
added: 0,
modified: 0,
deleted: 0,
renamed: 0,
};
for (const fileDiff of renderableFiles) {
const category = categorizeFileDiff(fileDiff);
counts[category]++;
}
return { all: renderableFiles.length, ...counts };
}, [renderableFiles]);

const filteredFiles = useMemo(() => {
if (selectedCategory === "all") return renderableFiles;
return renderableFiles.filter((fileDiff) => categorizeFileDiff(fileDiff) === selectedCategory);
}, [renderableFiles, selectedCategory]);

useEffect(() => {
if (diffOpen && !previousDiffOpenRef.current) {
setDiffWordWrap(false);
setSelectedCategory("all");
}
previousDiffOpenRef.current = diffOpen;
}, [diffOpen]);
Expand All @@ -313,166 +319,38 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
[activeCwd],
);

const selectTurn = useCallback(
(turnId: TurnId) => {
setSelectedTurn(turnId);
},
[setSelectedTurn],
);
const selectWholeConversation = useCallback(() => {
setSelectedTurn(null);
}, [setSelectedTurn]);
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="min-w-0 flex-1 [-webkit-app-region:no-drag]">
<div className="flex flex-wrap gap-1 py-0.5">
{CATEGORY_ORDER.map((category) => {
const count = categoryCounts[category];
if (category !== "all" && count === 0) return null;
return (
<button
key={category}
type="button"
className="shrink-0 rounded-md"
onClick={() => setSelectedCategory(category)}
>
<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, "locale", "en")}
</span>
<div
className={cn(
"rounded-md border px-2 py-1 text-left transition-colors",
selectedCategory === category
? "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">
{CATEGORY_LABELS[category]}
</span>
<span className="text-[9px] leading-tight opacity-70">{count}</span>
</div>
</div>
</div>
</button>
))}
</button>
);
})}
</div>
</div>
<div className="flex shrink-0 items-center gap-1 [-webkit-app-region:no-drag]">
Expand Down Expand Up @@ -561,7 +439,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
intersectionObserverMargin: 1200,
}}
>
{renderableFiles.map((fileDiff) => {
{filteredFiles.map((fileDiff) => {
const filePath = resolveFileDiffPath(fileDiff);
const fileKey = buildFileDiffRenderKey(fileDiff);
const themedFileKey = `${fileKey}:${resolvedTheme}`;
Expand Down
Loading