diff --git a/components/MessageView.tsx b/components/MessageView.tsx index 290c2a3d8..7f6684231 100644 --- a/components/MessageView.tsx +++ b/components/MessageView.tsx @@ -9,9 +9,9 @@ import { copyText } from "@/lib/clipboard"; import { useI18n } from "@/hooks/useI18n"; import { parseCompactionSummary } from "@/lib/compaction-summary"; import { getAssistantErrorMessage, getThinkingPreview, isEmptyThinkingBlock } from "@/lib/message-display"; -import { parseUnifiedPatch, type SplitDiffCell } from "@/lib/patch"; import { isEditToolName } from "@/lib/tool-names"; import { isThinkingExpandedByDefault, THINKING_EXPANDED_EVENT } from "@/lib/thinking-expansion-preference"; +import { SplitPatchView } from "./SplitPatchView"; import { TurnWrittenFiles } from "./TurnWrittenFiles"; import type { WrittenFile } from "@/lib/turn-written-files"; import { skillExpansionToCommand } from "@/lib/slash-display"; @@ -1149,206 +1149,6 @@ function PairedDiffResult({ diff }: { ); } -function SplitPatchView({ text }: { text: string }) { - const { t } = useI18n(); - const files = useMemo(() => parseUnifiedPatch(text), [text]); - if (!files) return ; - const showFileHeaders = files.length > 1; - - return ( -
- {files.map((file, fileIndex) => ( -
- {showFileHeaders && ( -
- - -
- )} - -
- {file.rows.map((row, rowIndex) => { - if (row.type === "hunk") { - return null; - } - - return ( -
- - -
- ); - })} -
-
- ))} -
- ); -} - -function SplitDiffHeader({ title, side }: { title: string; side: "left" | "right" }) { - return ( -
- {title} -
- ); -} - -function SplitDiffCellView({ cell, side }: { cell: SplitDiffCell; side: "left" | "right" }) { - const bg = - cell.type === "added" - ? "rgba(34,197,94,0.12)" - : cell.type === "removed" - ? "rgba(248,113,113,0.13)" - : cell.type === "empty" - ? "var(--bg-subtle)" - : "transparent"; - const marker = - cell.type === "added" ? "+" : cell.type === "removed" ? "-" : " "; - const markerColor = - cell.type === "added" ? "#22c55e" : cell.type === "removed" ? "#f87171" : "var(--text-dim)"; - - return ( -
- - {cell.lineNo ?? ""} - - - {marker} - - - {cell.text || "\u00a0"} - -
- ); -} - -function PatchTextView({ text }: { text: string }) { - const lines = text.split(/\r?\n/); - - return ( -
- {lines.map((line, i) => { - const kind = - line.startsWith("@@") ? "hunk" : - line.startsWith("+") && !line.startsWith("+++") ? "added" : - line.startsWith("-") && !line.startsWith("---") ? "removed" : - "context"; - const bg = - kind === "added" ? "rgba(34,197,94,0.12)" : - kind === "removed" ? "rgba(248,113,113,0.13)" : - kind === "hunk" ? "rgba(96,165,250,0.12)" : - "transparent"; - const color = - kind === "added" ? "#22c55e" : - kind === "removed" ? "#f87171" : - kind === "hunk" ? "var(--accent)" : - "var(--text)"; - - return ( -
- - {i + 1} - - - {line || "\u00a0"} - -
- ); - })} -
- ); -} - function getResultDiff(result: ToolResultMessage): ResultDiff | null { const details = (result as ToolResultMessage & { details?: unknown }).details; if (!isRecord(details)) return null; diff --git a/components/SplitPatchView.tsx b/components/SplitPatchView.tsx new file mode 100644 index 000000000..cd0c411c3 --- /dev/null +++ b/components/SplitPatchView.tsx @@ -0,0 +1,205 @@ +"use client"; + +import { useMemo } from "react"; +import { useI18n } from "@/hooks/useI18n"; +import { parseUnifiedPatch, type SplitDiffCell } from "@/lib/patch"; + +export function SplitPatchView({ text }: { text: string }) { + const { t } = useI18n(); + const files = useMemo(() => parseUnifiedPatch(text), [text]); + if (!files) return ; + const showFileHeaders = files.length > 1; + + return ( +
+ {files.map((file, fileIndex) => ( +
+ {showFileHeaders && ( +
+ + +
+ )} + +
+ {file.rows.map((row, rowIndex) => { + if (row.type === "hunk") { + return null; + } + + return ( +
+ + +
+ ); + })} +
+
+ ))} +
+ ); +} + +function SplitDiffHeader({ title, side }: { title: string; side: "left" | "right" }) { + return ( +
+ {title} +
+ ); +} + +function SplitDiffCellView({ cell, side }: { cell: SplitDiffCell; side: "left" | "right" }) { + const bg = + cell.type === "added" + ? "rgba(34,197,94,0.12)" + : cell.type === "removed" + ? "rgba(248,113,113,0.13)" + : cell.type === "empty" + ? "var(--bg-subtle)" + : "transparent"; + const marker = + cell.type === "added" ? "+" : cell.type === "removed" ? "-" : " "; + const markerColor = + cell.type === "added" ? "#22c55e" : cell.type === "removed" ? "#f87171" : "var(--text-dim)"; + + return ( +
+ + {cell.lineNo ?? ""} + + + {marker} + + + {cell.text || "\u00a0"} + +
+ ); +} + +function PatchTextView({ text }: { text: string }) { + const lines = text.split(/\r?\n/); + + return ( +
+ {lines.map((line, i) => { + const kind = + line.startsWith("@@") ? "hunk" : + line.startsWith("+") && !line.startsWith("+++") ? "added" : + line.startsWith("-") && !line.startsWith("---") ? "removed" : + "context"; + const bg = + kind === "added" ? "rgba(34,197,94,0.12)" : + kind === "removed" ? "rgba(248,113,113,0.13)" : + kind === "hunk" ? "rgba(96,165,250,0.12)" : + "transparent"; + const color = + kind === "added" ? "#22c55e" : + kind === "removed" ? "#f87171" : + kind === "hunk" ? "var(--accent)" : + "var(--text)"; + + return ( +
+ + {i + 1} + + + {line || "\u00a0"} + +
+ ); + })} +
+ ); +} diff --git a/components/TurnWrittenFiles.test.mjs b/components/TurnWrittenFiles.test.mjs index 361c86c84..53bddf907 100644 --- a/components/TurnWrittenFiles.test.mjs +++ b/components/TurnWrittenFiles.test.mjs @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { readFileSync } from "node:fs"; import { createJiti } from "jiti"; const jiti = createJiti(import.meta.url, { @@ -17,6 +18,15 @@ function render(props) { ); } +const WRITE_PATCH = [ + "diff --git a/report.html b/report.html", + "new file mode 100644", + "--- /dev/null", + "+++ b/report.html", + "@@ -0,0 +1,1 @@", + "+hello", +].join("\n"); + test("renders a button per file showing the basename and full path", () => { const html = render({ files: [{ filePath: "/abs/out/report.html" }, { filePath: "/abs/out/data.json" }], @@ -32,3 +42,65 @@ test("renders a button per file showing the basename and full path", () => { test("renders nothing when no files were written", () => { assert.equal(render({ files: [], onOpenFile() {} }), ""); }); + +test("labels the chip as a turn-diff toggle instead of opening the file", () => { + const html = render({ + files: [{ filePath: "/abs/out/report.html", patch: WRITE_PATCH }], + onOpenFile() {}, + }); + assert.match(html, /aria-label="Show Diff for report.html"/); + assert.match(html, /aria-expanded="false"/); + assert.doesNotMatch(html, />hello { + const html = render({ + files: [{ filePath: "/abs/out/report.html", patch: WRITE_PATCH }], + onOpenFile() {}, + }); + assert.match(html, /aria-label="Open report.html"/); +}); + +test("shows an empty-state label when a chip has no patch", () => { + const source = readFileSync(new URL("./TurnWrittenFiles.tsx", import.meta.url), "utf8"); + assert.match(source, /chat\.noTurnDiff/); + const html = render({ + files: [{ filePath: "/abs/out/report.html" }], + onOpenFile() {}, + }); + assert.match(html, /aria-label="Show Diff for report.html"/); +}); + +test("stops the open-file control from toggling the turn diff", () => { + const source = readFileSync(new URL("./TurnWrittenFiles.tsx", import.meta.url), "utf8"); + assert.match(source, /stopPropagation/); + assert.match(source, /onOpenFile\?\.\(filePath\)/); +}); + +test("shows added and deleted line counts on each chip", () => { + const html = render({ + files: [{ + filePath: "/abs/out/report.html", + patch: WRITE_PATCH, + additions: 4, + deletions: 2, + }], + onOpenFile() {}, + }); + assert.match(html, />\+4-2 { + const html = render({ + files: [{ + filePath: "/abs/out/report.html", + patch: WRITE_PATCH, + additions: 1, + deletions: 0, + }], + onOpenFile() {}, + }); + assert.match(html, />\+1-0 void; }) { const { t } = useI18n(); + const [expandedPath, setExpandedPath] = useState(null); if (files.length === 0) return null; + const expanded = files.find((file) => file.filePath === expandedPath); + return ( -
- {files.map(({ filePath }) => { - const name = getFileName(filePath); - return ( - - ); - })} +
+
+ {files.map(({ filePath, additions = 0, deletions = 0 }) => { + const name = getFileName(filePath); + const isExpanded = expandedPath === filePath; + return ( + + + + + ); + })} +
+ {expanded && ( + expanded.patch + ? ( +
+ +
+ ) + : ( +
+ {t("chat.noTurnDiff")} +
+ ) + )}
); } diff --git a/lib/i18n/messages/en.ts b/lib/i18n/messages/en.ts index 008f0de57..b734acea3 100644 --- a/lib/i18n/messages/en.ts +++ b/lib/i18n/messages/en.ts @@ -315,6 +315,9 @@ export const enLocale: LocalePlugin = { "chat.expandProcess": "Expand process details", "chat.filesWritten": "Files changed", "chat.openWrittenFile": "Open {name}", + "chat.showTurnDiff": "Show Diff for {name}", + "chat.hideTurnDiff": "Hide Diff for {name}", + "chat.noTurnDiff": "No Diff to show", "chat.loadEarlier": "Scroll up to load earlier messages", "chat.extensionRequest": "extension request", "chat.extensionExpiresIn": "expires in {seconds}s", diff --git a/lib/i18n/messages/zh-CN.ts b/lib/i18n/messages/zh-CN.ts index ef1e4ae84..a9321813e 100644 --- a/lib/i18n/messages/zh-CN.ts +++ b/lib/i18n/messages/zh-CN.ts @@ -315,6 +315,9 @@ export const zhCNLocale: LocalePlugin = { "chat.expandProcess": "展开处理详情", "chat.filesWritten": "改动的文件", "chat.openWrittenFile": "打开 {name}", + "chat.showTurnDiff": "显示 {name} 的 Diff", + "chat.hideTurnDiff": "隐藏 {name} 的 Diff", + "chat.noTurnDiff": "没有可显示的 Diff", "chat.loadEarlier": "向上滚动以加载更早的消息", "chat.extensionRequest": "扩展请求", "chat.extensionExpiresIn": "{seconds} 秒后过期", diff --git a/lib/i18n/messages/zh-TW.ts b/lib/i18n/messages/zh-TW.ts index c45ef21b9..4b8a54916 100644 --- a/lib/i18n/messages/zh-TW.ts +++ b/lib/i18n/messages/zh-TW.ts @@ -315,6 +315,9 @@ export const zhTWLocale: LocalePlugin = { "chat.expandProcess": "展開處理詳細資料", "chat.filesWritten": "已變更的檔案", "chat.openWrittenFile": "開啟 {name}", + "chat.showTurnDiff": "顯示 {name} 的 Diff", + "chat.hideTurnDiff": "隱藏 {name} 的 Diff", + "chat.noTurnDiff": "沒有可顯示的 Diff", "chat.loadEarlier": "向上捲動以載入較早的訊息", "chat.extensionRequest": "擴充功能請求", "chat.extensionExpiresIn": "{seconds} 秒後過期", diff --git a/lib/patch.ts b/lib/patch.ts index b1488c4fc..226af476e 100644 --- a/lib/patch.ts +++ b/lib/patch.ts @@ -129,6 +129,31 @@ export function parseUnifiedPatch(text: string): SplitDiffFile[] | null { return parsed.length > 0 ? parsed : null; } +/** Count +/− lines the same way SplitPatchView renders them. */ +export function countPatchLineStats(patch: string): { additions: number; deletions: number } { + const files = parseUnifiedPatch(patch); + if (files) { + let additions = 0; + let deletions = 0; + for (const file of files) { + for (const row of file.rows) { + if (row.type !== "line") continue; + if (row.right.type === "added") additions += 1; + if (row.left.type === "removed") deletions += 1; + } + } + return { additions, deletions }; + } + + let additions = 0; + let deletions = 0; + for (const line of patch.split(/\r?\n/)) { + if (line.startsWith("+") && !line.startsWith("+++")) additions += 1; + else if (line.startsWith("-") && !line.startsWith("---")) deletions += 1; + } + return { additions, deletions }; +} + function cleanPatchPath(path: string): string { return path.split("\t")[0].trim(); } diff --git a/lib/turn-written-files.test.mjs b/lib/turn-written-files.test.mjs index ed8bf3e59..6139318ad 100644 --- a/lib/turn-written-files.test.mjs +++ b/lib/turn-written-files.test.mjs @@ -4,13 +4,14 @@ import { createJiti } from "jiti"; const jiti = createJiti(import.meta.url, { tsconfigPaths: true }); const { extractTurnWrittenFiles } = await jiti.import("./turn-written-files.ts"); +const { TEXT_PREVIEW_MAX_BYTES } = await jiti.import("./file-types.ts"); function toolCall(toolCallId, toolName, input) { return { type: "toolCall", toolCallId, toolName, input }; } -function okResult(toolCallId) { - return { role: "toolResult", toolCallId, content: [{ type: "text", text: "ok" }] }; +function okResult(toolCallId, details) { + return { role: "toolResult", toolCallId, content: [{ type: "text", text: "ok" }], details }; } function errorResult(toolCallId) { @@ -135,3 +136,163 @@ test("returns an empty array for an empty or text-only turn", () => { assert.deepEqual(paths([], results()), []); assert.deepEqual(paths([{ type: "text", text: "hi" }], results()), []); }); + +const EDIT_PATCH = [ + "--- a/a.ts", + "+++ b/a.ts", + "@@ -1 +1 @@", + "-old", + "+new", + "", +].join("\n"); + +test("attaches an edit tool result patch to the written file", () => { + const content = [toolCall("1", "edit", { path: "/abs/src/a.ts" })]; + const files = extractTurnWrittenFiles(content, results(okResult("1", { patch: EDIT_PATCH }))); + assert.equal(files[0]?.filePath, "/abs/src/a.ts"); + assert.equal(files[0]?.patch, EDIT_PATCH); +}); + +test("prefers details.patch over details.diff for an edit", () => { + const content = [toolCall("1", "edit", { path: "/abs/src/a.ts" })]; + const files = extractTurnWrittenFiles(content, results(okResult("1", { patch: EDIT_PATCH, diff: "-ignored\n+nope\n" }))); + assert.equal(files[0]?.patch, EDIT_PATCH); +}); + +test("falls back to details.diff when patch is absent", () => { + const content = [toolCall("1", "edit", { path: "/abs/src/a.ts" })]; + const files = extractTurnWrittenFiles(content, results(okResult("1", { diff: EDIT_PATCH }))); + assert.equal(files[0]?.patch, EDIT_PATCH); +}); + +test("synthesizes a new-file patch from a successful write's content", () => { + const content = [toolCall("1", "write", { file_path: "/abs/out/report.html", content: "hello\n" })]; + const files = extractTurnWrittenFiles(content, results(okResult("1"))); + assert.equal(files[0]?.filePath, "/abs/out/report.html"); + assert.match(files[0]?.patch ?? "", /new file mode 100644/); + assert.match(files[0]?.patch ?? "", /\+\+\+ b\/report\.html/); + assert.match(files[0]?.patch ?? "", /\+hello/); +}); + +test("omits patch for a write without content", () => { + const content = [toolCall("1", "write", { file_path: "/abs/out/report.html" })]; + const files = extractTurnWrittenFiles(content, results(okResult("1"))); + assert.equal(files[0]?.filePath, "/abs/out/report.html"); + assert.equal(files[0]?.patch, undefined); +}); + +test("concatenates patches when the same file is written then edited", () => { + const content = [ + toolCall("1", "write", { file_path: "/abs/out/report.html", content: "hello\n" }), + toolCall("2", "edit", { path: "/abs/out/report.html" }), + ]; + const files = extractTurnWrittenFiles(content, results( + okResult("1"), + okResult("2", { patch: EDIT_PATCH }), + )); + assert.equal(files.length, 1); + assert.match(files[0]?.patch ?? "", /\+hello/); + assert.match(files[0]?.patch ?? "", /\+new/); +}); + +test("omits a synthesized write patch when content exceeds the preview limit", () => { + const content = [toolCall("1", "write", { + file_path: "/abs/out/huge.txt", + content: "x".repeat(TEXT_PREVIEW_MAX_BYTES + 1), + })]; + const files = extractTurnWrittenFiles(content, results(okResult("1"))); + assert.equal(files[0]?.filePath, "/abs/out/huge.txt"); + assert.equal(files[0]?.patch, undefined); +}); + +test("counts added and deleted lines from an edit patch", () => { + const content = [toolCall("1", "edit", { path: "/abs/src/a.ts" })]; + const files = extractTurnWrittenFiles(content, results(okResult("1", { patch: EDIT_PATCH }))); + assert.equal(files[0]?.additions, 1); + assert.equal(files[0]?.deletions, 1); +}); + +test("counts added lines from a synthesized write patch", () => { + const content = [toolCall("1", "write", { file_path: "/abs/out/report.html", content: "hello\nworld\n" })]; + const files = extractTurnWrittenFiles(content, results(okResult("1"))); + assert.equal(files[0]?.additions, 2); + assert.equal(files[0]?.deletions, 0); +}); + +test("sums line counts when the same file is written then edited", () => { + const content = [ + toolCall("1", "write", { file_path: "/abs/out/report.html", content: "hello\n" }), + toolCall("2", "edit", { path: "/abs/out/report.html" }), + ]; + const files = extractTurnWrittenFiles(content, results( + okResult("1"), + okResult("2", { patch: EDIT_PATCH }), + )); + assert.equal(files[0]?.additions, 2); + assert.equal(files[0]?.deletions, 1); +}); + +test("uses zero line counts when no patch is available", () => { + const content = [toolCall("1", "write", { file_path: "/abs/out/report.html" })]; + const files = extractTurnWrittenFiles(content, results(okResult("1"))); + assert.equal(files[0]?.additions, 0); + assert.equal(files[0]?.deletions, 0); +}); + +test("counts in-hunk lines that look like ---/+++ file headers", () => { + const patch = [ + "--- a/notes.md", + "+++ b/notes.md", + "@@ -1,2 +1,2 @@", + "--- old bullet", + "+++ new bullet", + "", + ].join("\n"); + const content = [toolCall("1", "edit", { path: "/abs/notes.md" })]; + const files = extractTurnWrittenFiles(content, results(okResult("1", { patch }))); + assert.equal(files[0]?.additions, 1); + assert.equal(files[0]?.deletions, 1); +}); + +test("does not count absolute-path ---/+++ headers as line stats", () => { + // pi's edit tool emits `--- /abs/path` headers, not `--- a/file`. + const patch = [ + "--- /Users/kitten9455/iMile/smip/README.md", + "+++ /Users/kitten9455/iMile/smip/README.md", + "@@ -28,4 +28,8 @@", + " bun run dev:all", + " ```", + " ", + " context line", + "+", + "+```ts", + '+console.log("test");', + "+```", + "", + ].join("\n"); + const content = [toolCall("1", "edit", { path: "/Users/kitten9455/iMile/smip/README.md" })]; + const files = extractTurnWrittenFiles(content, results(okResult("1", { patch }))); + assert.equal(files[0]?.additions, 4); + assert.equal(files[0]?.deletions, 0); +}); + +test("counts replace hunks from absolute-path patches as visible +/− lines", () => { + const patch = [ + "--- /Users/kitten9455/iMile/smip/README.md", + "+++ /Users/kitten9455/iMile/smip/README.md", + "@@ -30,6 +30,7 @@", + " ", + " context line", + " ", + " ```ts", + '-console.log("test");', + '+console.log("line a");', + '+console.log("line b");', + " ```", + "", + ].join("\n"); + const content = [toolCall("1", "edit", { path: "/Users/kitten9455/iMile/smip/README.md" })]; + const files = extractTurnWrittenFiles(content, results(okResult("1", { patch }))); + assert.equal(files[0]?.additions, 2); + assert.equal(files[0]?.deletions, 1); +}); diff --git a/lib/turn-written-files.ts b/lib/turn-written-files.ts index 2fded76db..f3527b7c7 100644 --- a/lib/turn-written-files.ts +++ b/lib/turn-written-files.ts @@ -1,10 +1,17 @@ import type { AssistantContentBlock, ToolResultMessage } from "./types"; import { resolveLocalFilePath } from "./file-links"; +import { getFileName } from "./file-paths"; +import { TEXT_PREVIEW_MAX_BYTES } from "./file-types"; +import { countPatchLineStats } from "./patch"; import { isEditToolName, isWriteToolName } from "./tool-names"; export interface WrittenFile { /** Resolved absolute path of a file this turn wrote. */ filePath: string; + /** Unified patch for this turn's writes/edits, when one can be derived. */ + patch?: string; + additions: number; + deletions: number; } function isFileWritingToolName(toolName: string): boolean { @@ -17,6 +24,60 @@ function readToolPath(input: Record | undefined): string | null return typeof value === "string" && value.length > 0 ? value : null; } +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function readToolResultPatch(result: ToolResultMessage): string | null { + if (!isRecord(result.details)) return null; + if (typeof result.details.patch === "string" && result.details.patch.length > 0) { + return result.details.patch; + } + if (typeof result.details.diff === "string" && result.details.diff.length > 0) { + return result.details.diff; + } + return null; +} + +function createAddedFilePatch(gitPath: string, content: string): string { + const hasTrailingNewline = content.endsWith("\n"); + const lines = content.split("\n"); + if (hasTrailingNewline) lines.pop(); + const body = lines.map((line) => `+${line}`).join("\n"); + const noNewlineMarker = !hasTrailingNewline && lines.length > 0 + ? "\n\\ No newline at end of file" + : ""; + return [ + `diff --git a/${gitPath} b/${gitPath}`, + "new file mode 100644", + "--- /dev/null", + `+++ b/${gitPath}`, + `@@ -0,0 +1,${lines.length} @@`, + `${body}${noNewlineMarker}`, + ].join("\n"); +} + +function readCallPatch( + toolName: string, + input: Record | undefined, + result: ToolResultMessage, + displayPath: string, +): string | null { + const fromResult = readToolResultPatch(result); + if (fromResult) return fromResult; + if (!isWriteToolName(toolName)) return null; + const content = input?.content; + if (typeof content !== "string") return null; + if (content.length > TEXT_PREVIEW_MAX_BYTES) return null; + return createAddedFilePatch(displayPath, content); +} + +function appendPatch(existing: string | undefined, next: string | null): string | undefined { + if (!next) return existing; + if (!existing) return next; + return `${existing}\n${next}`; +} + /** * Collect the distinct files a single assistant turn actually wrote. * @@ -26,13 +87,15 @@ function readToolPath(input: Record | undefined): string | null * source here; the tool call is the record of what happened. * * Paths are resolved against `cwd`, deduped, and kept in first-seen order. + * When a patch can be derived from the tool result or write content, it is + * attached and later calls against the same path are concatenated. */ export function extractTurnWrittenFiles( content: AssistantContentBlock[], toolResults: Map | undefined, cwd?: string, ): WrittenFile[] { - const seen = new Set(); + const byPath = new Map(); const writtenFiles: WrittenFile[] = []; for (const block of content) { @@ -51,9 +114,21 @@ export function extractTurnWrittenFiles( const filePath = resolveLocalFilePath(rawPath, cwd); if (!filePath) continue; - if (seen.has(filePath)) continue; - seen.add(filePath); - writtenFiles.push({ filePath }); + let entry = byPath.get(filePath); + if (!entry) { + entry = { filePath, additions: 0, deletions: 0 }; + byPath.set(filePath, entry); + writtenFiles.push(entry); + } + + const patch = readCallPatch(block.toolName, block.input, result, getFileName(filePath)); + const nextPatch = appendPatch(entry.patch, patch); + if (nextPatch) { + entry.patch = nextPatch; + const stats = countPatchLineStats(nextPatch); + entry.additions = stats.additions; + entry.deletions = stats.deletions; + } } return writtenFiles;