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).
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
<span className="ml-1 select-none rounded bg-surface-3 px-1 font-sans text-[10px] text-fg-muted">
|
||||
{t("changes.lineElided", { size })}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
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" ? "-" : " "}
|
||||
</span>
|
||||
<Code text={line.text} lang={lang} emph={emph} />
|
||||
<Elided n={line.elided} />
|
||||
</span>
|
||||
{bar && <HunkBar change={bar} />}
|
||||
</Row>
|
||||
@@ -621,6 +634,7 @@ function SplitLine({
|
||||
/>
|
||||
<span className="whitespace-pre px-1">
|
||||
{line ? <Code text={line.text} lang={lang} emph={e} /> : null}
|
||||
{line ? <Elided n={line.elided} /> : null}
|
||||
</span>
|
||||
{bar && <HunkBar change={bar} />}
|
||||
</Row>
|
||||
|
||||
@@ -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: '<video src="AAAA', elided: 3_000_000 },
|
||||
{ type: "add", oldLine: null, newLine: 1, text: '<video src="BBBB', elided: 3_000_000 },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it("says how much of the row it hides, in both layouts", () => {
|
||||
const { unmount } = render(<LaneDiffView file={CUT} layout="unified" />);
|
||||
expect(screen.getAllByText(/3M more characters on this line/)).toHaveLength(2);
|
||||
unmount();
|
||||
render(<LaneDiffView file={CUT} layout="split" />);
|
||||
expect(screen.getAllByText(/3M more characters on this line/)).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -92,6 +92,7 @@
|
||||
"changes.expandFile": "Expand {{path}}",
|
||||
"changes.fileGone": "That file no longer has changes in this range.",
|
||||
"changes.fileTruncated": "Diff cut short — open the file in an editor for the rest.",
|
||||
"changes.lineElided": "… {{size}} more characters on this line",
|
||||
"changes.commentLine": "Comment on line {{n}} — pasted into this workspace's pane",
|
||||
"changes.commentOn": "Comment on {{at}} — pasted into the pane you used last; nothing is sent until you press Enter there",
|
||||
"changes.commentSend": "Paste into pane",
|
||||
|
||||
@@ -92,6 +92,7 @@
|
||||
"changes.expandFile": "Mở {{path}}",
|
||||
"changes.fileGone": "File này không còn thay đổi nào trong phạm vi đang xem.",
|
||||
"changes.fileTruncated": "Diff bị cắt bớt — mở file trong editor để xem phần còn lại.",
|
||||
"changes.lineElided": "… còn {{size}} ký tự trên dòng này",
|
||||
"changes.commentLine": "Comment dòng {{n}} — dán vào pane của workspace này",
|
||||
"changes.commentOn": "Comment {{at}} — dán vào pane bạn dùng gần nhất; chưa gửi gì cho tới khi bạn nhấn Enter ở đó",
|
||||
"changes.commentSend": "Dán vào pane",
|
||||
|
||||
@@ -2974,6 +2974,9 @@ export interface LaneDiffLine {
|
||||
/** Line number on the right (after) side; null on a removed line. */
|
||||
newLine: number | null;
|
||||
text: string;
|
||||
/** Characters cut off the end of a very long row (a base64 blob, a minified
|
||||
* bundle): `text` holds only its start. Absent on a whole row. */
|
||||
elided?: number;
|
||||
}
|
||||
|
||||
/** `GET /api/lanes/:id/git/blame` — Annotate: who last changed each line of
|
||||
|
||||
@@ -817,6 +817,11 @@ that file.
|
||||
- Three caps guard the response: **300** files listed, **200 KB** of patch per
|
||||
file, **1 MB** of patch in a `full` read. A cap that bit sets `truncated` on
|
||||
the response and on the file it cut.
|
||||
- A row longer than **4,000** characters (an inlined base64 image or video, a
|
||||
minified bundle) comes back cut to its first 1,000 with `elided: <characters
|
||||
hidden>`, BEFORE the per-file cap is applied, so one huge line no longer eats
|
||||
the hunks after it. Each file's raw patch is read up to 16 M characters; past
|
||||
that it is still `truncated`.
|
||||
- Read-only, no same-origin guard. `available: false` (HTTP **200**) for a cwd
|
||||
git cannot read; `range=branch` compares against the workspace's base branch,
|
||||
else the repo's default branch (what `origin/HEAD` names, else `main`/`master`)
|
||||
|
||||
@@ -473,6 +473,55 @@ describe("reads larger than the git() buffer report truncated, not missing", ()
|
||||
});
|
||||
});
|
||||
|
||||
describe("a page with megabyte-long lines (inlined base64) keeps every hunk", () => {
|
||||
const blob = (c) => c.repeat(3 * 1024 * 1024); // one 3 MB line
|
||||
|
||||
it("cuts the long rows down and still shows the hunks after them", async () => {
|
||||
const body = (mid, end) =>
|
||||
[
|
||||
"<h1>title</h1>",
|
||||
...Array.from({ length: 40 }, (_, i) => `row ${i}`),
|
||||
mid,
|
||||
...Array.from({ length: 40 }, (_, i) => `tail ${i}`),
|
||||
end,
|
||||
"",
|
||||
].join("\n");
|
||||
write(LANE, "deck.html", body(`<video src="${blob("A")}">`, "<p>old end</p>"));
|
||||
g(LANE, "add", "-A");
|
||||
g(LANE, "commit", "-qm", "deck");
|
||||
write(
|
||||
LANE,
|
||||
"deck.html",
|
||||
body(`<video src="${blob("B")}">`, "<p>new end</p>").replace(
|
||||
"<h1>title</h1>",
|
||||
"<h1>new title</h1>"
|
||||
)
|
||||
);
|
||||
const out = await collectChanges(LANE, { range: "work", file: "deck.html" });
|
||||
const f = out.files[0];
|
||||
assert.equal(f.truncated, undefined);
|
||||
assert.equal(out.truncated, false);
|
||||
const lines = f.hunks.flatMap((h) => h.lines);
|
||||
const long = lines.filter((l) => l.elided);
|
||||
assert.equal(long.length, 2);
|
||||
for (const l of long) {
|
||||
assert.ok(l.text.length < 2000);
|
||||
assert.equal(l.text.length + l.elided, '<video src="'.length + 3 * 1024 * 1024 + 2);
|
||||
}
|
||||
// The change AFTER the 3 MB rows survives: the cap no longer eats it.
|
||||
assert.ok(lines.some((l) => l.type === "add" && l.text === "<p>new end</p>"));
|
||||
assert.ok(lines.some((l) => l.type === "add" && l.text === "<h1>new title</h1>"));
|
||||
});
|
||||
|
||||
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"}}';
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
+55
-5
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user