diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 6c158dc8..1331b76d 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -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"; @@ -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"; @@ -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 = { + all: "All", + added: "Added", + modified: "Modified", + deleted: "Deleted", + renamed: "Renamed", +}; + +function categorizeFileDiff(fileDiff: FileDiffMetadata): Exclude { + 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; } @@ -157,17 +168,13 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { const { resolvedTheme } = useTheme(); const [diffRenderMode, setDiffRenderMode] = useState("stacked"); const [diffWordWrap, setDiffWordWrap] = useState(false); + const [selectedCategory, setSelectedCategory] = useState("all"); const patchViewportRef = useRef(null); - const turnStripRef = useRef(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) => @@ -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( @@ -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, }), ); @@ -284,9 +270,29 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { ); }, [renderablePatch]); + const categoryCounts = useMemo(() => { + const counts: Record, 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]); @@ -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) => { - 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("[data-turn-chip-selected='true']"); - selectedChip?.scrollIntoView({ block: "nearest", inline: "nearest", behavior: "smooth" }); - }, [selectedTurn?.turnId, selectedTurnId]); - const headerRow = ( <> -
- {canScrollTurnStripLeft && ( -
- )} - {canScrollTurnStripRight && ( -
- )} - - -
- - {orderedTurnDiffSummaries.map((summary) => ( - - ))} + + ); + })}
@@ -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}`;