diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index ff8900d1d0d2e0860faa082ff43b736b3dd427ed..2b3601bc3f0221b09d5f2684d905273fde15e761 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -63,11 +63,17 @@ import { MAX_THREAD_TERMINAL_COUNT, type ChatMessage, type Thread, + type TurnDiffFileChange, type TurnDiffSummary, } from "../types"; import { basenameOfPath, getVscodeIconUrlForEntry } from "../vscode-icons"; import { useTheme } from "../hooks/useTheme"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; +import { + buildTurnDiffTree, + summarizeTurnDiffStats, + type TurnDiffTreeNode, +} from "../lib/turnDiffTree"; import BranchToolbar from "./BranchToolbar"; import GitActionsControl from "./GitActionsControl"; import { @@ -3127,6 +3133,126 @@ const MessageCopyButton = memo(function MessageCopyButton({ text }: { text: stri ); }); +function hasNonZeroStat(stat: { additions: number; deletions: number }): boolean { + return stat.additions > 0 || stat.deletions > 0; +} + +const DiffStatLabel = memo(function DiffStatLabel(props: { + additions: number; + deletions: number; + showParentheses?: boolean; +}) { + const { additions, deletions, showParentheses = false } = props; + return ( + <> + {showParentheses && (} + +{additions} + / + -{deletions} + {showParentheses && )} + + ); +}); + +function buildInitiallyExpandedDirectoryState( + nodes: ReadonlyArray, +): Record { + const expandedState: Record = {}; + for (const node of nodes) { + if (node.kind === "directory") { + expandedState[node.path] = true; + } + } + return expandedState; +} + +const ChangedFilesTree = memo(function ChangedFilesTree(props: { + turnId: TurnId; + files: ReadonlyArray; + onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; +}) { + const { files, onOpenTurnDiff, turnId } = props; + const treeNodes = useMemo(() => buildTurnDiffTree(files), [files]); + const [expandedDirectories, setExpandedDirectories] = useState>( + () => buildInitiallyExpandedDirectoryState(treeNodes), + ); + + const toggleDirectory = useCallback((pathValue: string) => { + setExpandedDirectories((current) => ({ + ...current, + [pathValue]: !current[pathValue], + })); + }, []); + + const renderTreeNode = (node: TurnDiffTreeNode, depth: number) => { + const leftPadding = 8 + depth * 14; + if (node.kind === "directory") { + const isExpanded = expandedDirectories[node.path] ?? depth === 0; + return ( +
+ + {isExpanded && ( +
+ {node.children.map((childNode) => renderTreeNode(childNode, depth + 1))} +
+ )} +
+ ); + } + + return ( + + ); + }; + + return ( +
+ {treeNodes.map((node) => renderTreeNode(node, 0))} +
+ ); +}); + interface MessagesTimelineProps { hasMessages: boolean; isWorking: boolean; @@ -3531,33 +3657,20 @@ const MessagesTimeline = memo(function MessagesTimeline({ if (!turnSummary) return null; const checkpointFiles = turnSummary.files; if (checkpointFiles.length === 0) return null; - const summaryStat = checkpointFiles.reduce( - (acc, file) => { - if ( - typeof file.additions !== "number" || - typeof file.deletions !== "number" - ) { - return acc; - } - return { - additions: acc.additions + file.additions, - deletions: acc.deletions + file.deletions, - }; - }, - { additions: 0, deletions: 0 }, - ); + const summaryStat = summarizeTurnDiffStats(checkpointFiles); const changedFileCountLabel = String(checkpointFiles.length); return (

Changed files ({changedFileCountLabel}) - {(summaryStat.additions > 0 || summaryStat.deletions > 0) && ( + {hasNonZeroStat(summaryStat) && ( <> • - +{summaryStat.additions} - / - -{summaryStat.deletions} + )}

@@ -3572,40 +3685,12 @@ const MessagesTimeline = memo(function MessagesTimeline({ View diff
-
- {checkpointFiles.map((file) => ( - - ))} -
+
); })()} diff --git a/apps/web/src/lib/turnDiffTree.test.ts b/apps/web/src/lib/turnDiffTree.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..f852e40c61f8fea829f06ab337d8cee0466c6f5f --- /dev/null +++ b/apps/web/src/lib/turnDiffTree.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from "vitest"; + +import { buildTurnDiffTree, summarizeTurnDiffStats } from "./turnDiffTree"; + +describe("summarizeTurnDiffStats", () => { + it("sums only files with numeric additions/deletions", () => { + const stat = summarizeTurnDiffStats([ + { path: "README.md", additions: 3, deletions: 1 }, + { path: "docs/notes.md" }, + { path: "src/index.ts", additions: 5, deletions: 2 }, + ]); + + expect(stat).toEqual({ additions: 8, deletions: 3 }); + }); +}); + +describe("buildTurnDiffTree", () => { + it("builds nested directory nodes with aggregated stats", () => { + const tree = buildTurnDiffTree([ + { path: "src/index.ts", additions: 2, deletions: 1 }, + { path: "src/components/Button.tsx", additions: 4, deletions: 2 }, + { path: "README.md", additions: 1, deletions: 0 }, + ]); + + expect(tree).toEqual([ + { + kind: "directory", + name: "src", + path: "src", + stat: { additions: 6, deletions: 3 }, + children: [ + { + kind: "directory", + name: "components", + path: "src/components", + stat: { additions: 4, deletions: 2 }, + children: [ + { + kind: "file", + name: "Button.tsx", + path: "src/components/Button.tsx", + stat: { additions: 4, deletions: 2 }, + }, + ], + }, + { + kind: "file", + name: "index.ts", + path: "src/index.ts", + stat: { additions: 2, deletions: 1 }, + }, + ], + }, + { + kind: "file", + name: "README.md", + path: "README.md", + stat: { additions: 1, deletions: 0 }, + }, + ]); + }); + + it("keeps files without stat values and excludes them from directory totals", () => { + const tree = buildTurnDiffTree([ + { path: "docs/notes.md" }, + { path: "docs/todo.md", additions: 1, deletions: 1 }, + ]); + + expect(tree).toEqual([ + { + kind: "directory", + name: "docs", + path: "docs", + stat: { additions: 1, deletions: 1 }, + children: [ + { + kind: "file", + name: "notes.md", + path: "docs/notes.md", + stat: null, + }, + { + kind: "file", + name: "todo.md", + path: "docs/todo.md", + stat: { additions: 1, deletions: 1 }, + }, + ], + }, + ]); + }); + + it("normalizes file paths with windows separators", () => { + const tree = buildTurnDiffTree([{ path: "apps\\web\\src\\index.ts", additions: 2, deletions: 1 }]); + + expect(tree).toEqual([ + { + kind: "directory", + name: "apps", + path: "apps", + stat: { additions: 2, deletions: 1 }, + children: [ + { + kind: "directory", + name: "web", + path: "apps/web", + stat: { additions: 2, deletions: 1 }, + children: [ + { + kind: "directory", + name: "src", + path: "apps/web/src", + stat: { additions: 2, deletions: 1 }, + children: [ + { + kind: "file", + name: "index.ts", + path: "apps/web/src/index.ts", + stat: { additions: 2, deletions: 1 }, + }, + ], + }, + ], + }, + ], + }, + ]); + }); +}); diff --git a/apps/web/src/lib/turnDiffTree.ts b/apps/web/src/lib/turnDiffTree.ts new file mode 100644 index 0000000000000000000000000000000000000000..126f96783af4aa8214617e6120b4c83109782c78 --- /dev/null +++ b/apps/web/src/lib/turnDiffTree.ts @@ -0,0 +1,148 @@ +import type { TurnDiffFileChange } from "../types"; + +export interface TurnDiffStat { + additions: number; + deletions: number; +} + +export interface TurnDiffTreeDirectoryNode { + kind: "directory"; + name: string; + path: string; + stat: TurnDiffStat; + children: TurnDiffTreeNode[]; +} + +export interface TurnDiffTreeFileNode { + kind: "file"; + name: string; + path: string; + stat: TurnDiffStat | null; +} + +export type TurnDiffTreeNode = TurnDiffTreeDirectoryNode | TurnDiffTreeFileNode; + +interface MutableDirectoryNode { + name: string; + path: string; + stat: TurnDiffStat; + directories: Map; + files: TurnDiffTreeFileNode[]; +} + +const SORT_LOCALE_OPTIONS: Intl.CollatorOptions = { numeric: true, sensitivity: "base" }; + +function normalizePathSegments(pathValue: string): string[] { + return pathValue + .replaceAll("\\", "/") + .split("/") + .map((segment) => segment.trim()) + .filter((segment) => segment.length > 0); +} + +function compareByName(a: { name: string }, b: { name: string }): number { + return a.name.localeCompare(b.name, undefined, SORT_LOCALE_OPTIONS); +} + +function readStat(file: TurnDiffFileChange): TurnDiffStat | null { + if (typeof file.additions !== "number" || typeof file.deletions !== "number") { + return null; + } + return { + additions: file.additions, + deletions: file.deletions, + }; +} + +function toTreeNodes(directory: MutableDirectoryNode): TurnDiffTreeNode[] { + const subdirectories: TurnDiffTreeDirectoryNode[] = Array.from(directory.directories.values()) + .toSorted(compareByName) + .map((subdirectory) => ({ + kind: "directory", + name: subdirectory.name, + path: subdirectory.path, + stat: { + additions: subdirectory.stat.additions, + deletions: subdirectory.stat.deletions, + }, + children: toTreeNodes(subdirectory), + })); + + const files = directory.files.toSorted(compareByName); + return [...subdirectories, ...files]; +} + +export function summarizeTurnDiffStats(files: ReadonlyArray): TurnDiffStat { + return files.reduce( + (acc, file) => { + const stat = readStat(file); + if (!stat) return acc; + return { + additions: acc.additions + stat.additions, + deletions: acc.deletions + stat.deletions, + }; + }, + { additions: 0, deletions: 0 }, + ); +} + +export function buildTurnDiffTree(files: ReadonlyArray): TurnDiffTreeNode[] { + const root: MutableDirectoryNode = { + name: "", + path: "", + stat: { additions: 0, deletions: 0 }, + directories: new Map(), + files: [], + }; + + for (const file of files) { + const segments = normalizePathSegments(file.path); + if (segments.length === 0) { + continue; + } + + const filePath = segments.join("/"); + const fileName = segments.at(-1); + if (!fileName) { + continue; + } + const stat = readStat(file); + const ancestors: MutableDirectoryNode[] = [root]; + let currentDirectory = root; + + for (const segment of segments.slice(0, -1)) { + const nextPath = currentDirectory.path ? `${currentDirectory.path}/${segment}` : segment; + const existing = currentDirectory.directories.get(segment); + if (existing) { + currentDirectory = existing; + } else { + const created: MutableDirectoryNode = { + name: segment, + path: nextPath, + stat: { additions: 0, deletions: 0 }, + directories: new Map(), + files: [], + }; + currentDirectory.directories.set(segment, created); + currentDirectory = created; + } + ancestors.push(currentDirectory); + } + + currentDirectory.files.push({ + kind: "file", + name: fileName, + path: filePath, + stat, + }); + + if (stat) { + for (const ancestor of ancestors) { + ancestor.stat.additions += stat.additions; + ancestor.stat.deletions += stat.deletions; + } + } + } + + return toTreeNodes(root); +}