From d97c403aab72c0728b5a821111ee9efe9569b588 Mon Sep 17 00:00:00 2001
From: nntrivi2001
Date: Fri, 30 Oct 2026 10:28:53 +0700
Subject: [PATCH] fix(changes): a huge line no longer cuts the diff short;
preview pages up to 64 MB
A page that inlines a video as base64 has rows of megabytes. Its patch was read
at the 200 KB per-file cap, so the first such row used the whole budget and
every hunk after it was lost ("Diff cut short"). The patch is now read up to
16 M characters, rows over 4,000 characters are cut to 1,000 with an `elided`
count the diff shows, and only then is the cap applied. Hunk actions re-read
git's own diff by header and are unchanged.
The HTML preview refused a page over 10 MB and showed only the file icon; a
self-contained 18 MB slide deck now opens (limit 64 MB).
---
client/src/components/HtmlFrame.tsx | 6 +-
client/src/components/lanes/LaneDiffView.tsx | 14 +++++
.../lanes/__tests__/LaneDiffView.test.tsx | 23 +++++++
client/src/i18n/locales/en/lanes.json | 1 +
client/src/i18n/locales/vi/lanes.json | 1 +
client/src/lib/types.ts | 3 +
docs/API.md | 5 ++
.../__tests__/git-diff-ops-hardening.test.js | 49 +++++++++++++++
server/__tests__/git-diff-ops.test.js | 19 +++---
server/lib/git-diff.js | 60 +++++++++++++++++--
10 files changed, 166 insertions(+), 15 deletions(-)
diff --git a/client/src/components/HtmlFrame.tsx b/client/src/components/HtmlFrame.tsx
index 52db2a26..87108212 100644
--- a/client/src/components/HtmlFrame.tsx
+++ b/client/src/components/HtmlFrame.tsx
@@ -15,8 +15,10 @@ import { FileText, Loader2 } from "lucide-react";
import { artifactDocument, frameSandbox } from "../lib/artifactView";
import { blobText, inlineHtml, type FetchFile } from "../lib/htmlInline";
-/** A page read by `fetchFile` is shown up to this size. */
-const HTML_MAX = 10 * 1024 * 1024;
+/** A page read by `fetchFile` is shown up to this size. A self-contained
+ * page (a slide deck with its video and images inlined as base64) is easily
+ * past 10 MB; the iframe renders far larger documents than this. */
+const HTML_MAX = 64 * 1024 * 1024;
export function HtmlFrame({
path,
diff --git a/client/src/components/lanes/LaneDiffView.tsx b/client/src/components/lanes/LaneDiffView.tsx
index ca1b9123..f7fba385 100644
--- a/client/src/components/lanes/LaneDiffView.tsx
+++ b/client/src/components/lanes/LaneDiffView.tsx
@@ -179,6 +179,18 @@ export function langFor(filePath: string) {
}
/** Tokenized line text. `emph` marks the span that actually changed. */
+/** The tail of a row the server cut down (`LaneDiffLine.elided`). */
+function Elided({ n }: { n?: number }) {
+ const { t, i18n } = useTranslation("lanes");
+ if (!n) return null;
+ const size = new Intl.NumberFormat(i18n.language, { notation: "compact" }).format(n);
+ return (
+
+ {t("changes.lineElided", { size })}
+
+ );
+}
+
function Code({ text, lang, emph }: { text: string; lang: string; emph?: [number, number] }) {
if (emph) {
const [from, to] = emph;
@@ -544,6 +556,7 @@ function UnifiedLine({
{line.type === "add" ? "+" : line.type === "del" ? "-" : " "}
+
{bar && }
@@ -621,6 +634,7 @@ function SplitLine({
/>
{line ? : null}
+ {line ? : null}
{bar && }
diff --git a/client/src/components/lanes/__tests__/LaneDiffView.test.tsx b/client/src/components/lanes/__tests__/LaneDiffView.test.tsx
index 3d57e841..defb2a3e 100644
--- a/client/src/components/lanes/__tests__/LaneDiffView.test.tsx
+++ b/client/src/components/lanes/__tests__/LaneDiffView.test.tsx
@@ -609,3 +609,26 @@ describe("LaneDiffView — line comments", () => {
expect(screen.queryByTestId("diff-comment-line-1")).toBeNull();
});
});
+
+describe("LaneDiffView — a row the server cut down", () => {
+ const CUT: LaneChangeFile = {
+ ...FILE,
+ hunks: [
+ {
+ ...FILE.hunks[0]!,
+ lines: [
+ { type: "del", oldLine: 1, newLine: null, text: '
"));
+ assert.ok(lines.some((l) => l.type === "add" && l.text === "new title
"));
+ });
+
+ it("leaves ordinary rows whole", async () => {
+ write(LANE, "a.txt", "one\nTWO\n");
+ const out = await collectChanges(LANE, { range: "work", file: "a.txt" });
+ const lines = out.files[0].hunks.flatMap((h) => h.lines);
+ assert.ok(lines.every((l) => l.elided === undefined));
+ assert.ok(lines.some((l) => l.text === "TWO"));
+ });
+});
+
describe("loop 2: secret file at any depth, symlinked .claude", () => {
const KEY = '{"env":{"ANTHROPIC_API_KEY":"sk-deep"}}';
diff --git a/server/__tests__/git-diff-ops.test.js b/server/__tests__/git-diff-ops.test.js
index 3338815e..6fb033f9 100644
--- a/server/__tests__/git-diff-ops.test.js
+++ b/server/__tests__/git-diff-ops.test.js
@@ -203,18 +203,21 @@ describe("collectChanges — work range", () => {
});
});
- it("still returns content when a huge file has no newline to cut on", async () => {
- // A minified bundle is ONE line. Cutting the patch at the last newline
- // inside the cap would land at index -1 here and hand back an empty
- // patch — a file changed by a megabyte rendering as "no textual change".
+ it("still returns content when a huge file is one line", async () => {
+ // A minified bundle is ONE line. It used to be cut at the per-file cap
+ // (and, cut at the last newline, could come back empty — a file changed
+ // by a megabyte rendering as "no textual change"); now the row itself is
+ // cut down and says how much it hides, and nothing is truncated.
const { MAX_FILE_BYTES } = require("../lib/git-diff");
write(LANE, "min.js", `${"x".repeat(MAX_FILE_BYTES + 5000)}\n`);
const out = await collectChanges(LANE, { range: "work", file: "min.js" });
const entry = out.files[0];
- assert.equal(entry.truncated, true);
- assert.equal(out.truncated, true);
- assert.ok(entry.hunks.length > 0, "a truncated patch still carries its hunk");
- assert.ok(entry.hunks[0].lines[0].text.startsWith("xxx"));
+ assert.equal(entry.truncated, undefined);
+ assert.equal(out.truncated, false);
+ assert.ok(entry.hunks.length > 0, "the patch still carries its hunk");
+ const row = entry.hunks[0].lines.find((l) => l.type === "add");
+ assert.ok(row.text.startsWith("xxx"));
+ assert.equal(row.text.length + row.elided, MAX_FILE_BYTES + 5000);
});
it("reports a binary file without trying to diff it", async () => {
diff --git a/server/lib/git-diff.js b/server/lib/git-diff.js
index 7fc8b230..c391d036 100644
--- a/server/lib/git-diff.js
+++ b/server/lib/git-diff.js
@@ -61,6 +61,19 @@ async function readCapped(dir, args, capChars) {
const MAX_FILES = 300;
/** Most patch bytes read for ONE file before its hunks are cut short. */
const MAX_FILE_BYTES = 200_000;
+/** Characters of one file's raw patch read before its long lines are cut down
+ * (`elideLongLines`) and `MAX_FILE_BYTES` applied. A page that embeds a video
+ * or a font as base64 is a few lines of megabytes each: read at
+ * `MAX_FILE_BYTES`, its first such line used the whole budget and every hunk
+ * after it was lost. Past this the patch is still truncated. */
+const MAX_PATCH_READ_CHARS = 16 * 1024 * 1024;
+/** A diff row longer than this is shown cut down to `LONG_LINE_KEEP`
+ * characters, with how many it hides (`elided` on the parsed line). */
+const LONG_LINE_CHARS = 4000;
+const LONG_LINE_KEEP = 1000;
+/** Ends a cut-down row in the raw patch: a private-use character, then the
+ * count of characters hidden. `parseUnifiedDiff` turns it into `elided`. */
+const ELIDED_MARK = "\uE000";
/** Most patch bytes across all files in a `full` (review-mode) response. */
const MAX_TOTAL_BYTES = 1_000_000;
/** An untracked file larger than this is reported without content. */
@@ -259,15 +272,23 @@ function parseUnifiedDiff(patch) {
// row of its own and carries no line number.
if (raw.startsWith("\\")) continue;
const marker = raw[0];
- const text = raw.slice(1);
+ let text = raw.slice(1);
+ // A row `elideLongLines` cut down: the text it kept, and how much it hid.
+ let elided = 0;
+ const cut = text.lastIndexOf(ELIDED_MARK);
+ if (cut >= 0 && /^\d+$/.test(text.slice(cut + 1))) {
+ elided = Number(text.slice(cut + 1));
+ text = text.slice(0, cut);
+ }
+ const extra = elided ? { elided } : {};
if (marker === "+") {
- current.lines.push({ type: "add", oldLine: null, newLine: newLine++, text });
+ current.lines.push({ type: "add", oldLine: null, newLine: newLine++, text, ...extra });
} else if (marker === "-") {
- current.lines.push({ type: "del", oldLine: oldLine++, newLine: null, text });
+ current.lines.push({ type: "del", oldLine: oldLine++, newLine: null, text, ...extra });
} else if (marker === " ") {
// An unchanged EMPTY line is " " — one space, no text. A truly empty
// string is the tail of the final "\n", not a row.
- current.lines.push({ type: "ctx", oldLine: oldLine++, newLine: newLine++, text });
+ current.lines.push({ type: "ctx", oldLine: oldLine++, newLine: newLine++, text, ...extra });
}
}
return hunks;
@@ -532,7 +553,27 @@ async function patchFor(dir, { range, baseRef, sha, file, untracked, context })
: range === "branch"
? ["diff", u, `${baseRef}...HEAD`, "--", file]
: ["diff", u, "HEAD", "--", file];
- return readCapped(dir, [...args, "--no-color", "--no-ext-diff"], MAX_FILE_BYTES).catch(() => "");
+ return readCapped(dir, [...args, "--no-color", "--no-ext-diff"], MAX_PATCH_READ_CHARS).catch(
+ () => ""
+ );
+}
+
+/**
+ * The patch with every row longer than `LONG_LINE_CHARS` cut down to its first
+ * `LONG_LINE_KEEP` characters plus `ELIDED_MARK` and the count it hides. Only
+ * what the panel SHOWS is cut: the hunk actions (`hunkPatch`, `selectionPatch`)
+ * re-read git's own diff and match hunks by header, which this never changes.
+ */
+function elideLongLines(patch) {
+ if (patch.length <= LONG_LINE_CHARS) return patch;
+ return patch
+ .split("\n")
+ .map((row) =>
+ row.length > LONG_LINE_CHARS && !row.startsWith("@@")
+ ? `${row.slice(0, LONG_LINE_KEEP + 1)}${ELIDED_MARK}${row.length - LONG_LINE_KEEP - 1}`
+ : row
+ )
+ .join("\n");
}
/**
@@ -648,7 +689,15 @@ async function collectChanges(
untracked: entry.untracked,
context: ctx,
});
+ // A read that hit its own ceiling came back partial: still cut short,
+ // however small eliding its long rows makes it.
+ const partial = patch.length >= MAX_PATCH_READ_CHARS;
+ patch = elideLongLines(patch);
const cap = Math.min(MAX_FILE_BYTES, budget);
+ if (partial) {
+ entry.truncated = true;
+ truncated = true;
+ }
if (patch.length > cap) {
// Cut on a line boundary so the last row is a whole line — but only
// when that boundary is near the cap. A minified file is one enormous
@@ -1334,6 +1383,7 @@ async function plainTreeAll(dir) {
}
module.exports = {
+ elideLongLines,
collectChanges,
CONFLICT_CODES,
contentSha,