Compare commits

..

5 Commits

Author SHA1 Message Date
nntrivi2001 4b8f59695b chore: v2.8.44 — Discard in the Changes panel takes only the ticked files and blocks 2026-10-30 13:05:48 +07:00
nntrivi2001 da7b86d5c8 fix(changes): Discard takes only the ticked files and blocks (#87)
The Changes panel's "Discard all" threw away every changed file, ticked or
not. It is now "Discard (n)": the same selection Commit sends — whole ticked
files as `paths`, a partly ticked file's ticked blocks as `hunks` — and is
disabled with nothing ticked. The server's `discard` accepts `hunks`
(`selectionPatch` in reverse, `git apply -R`), building every patch before
applying any so a stale selection undoes nothing.

Closes #87
2026-10-30 13:05:13 +07:00
nntrivi2001 efe21c79e9 chore: v2.8.43 — terminal glyphs stay sharp in every pane; a huge diff line no longer hides the rest 2026-10-30 10:34:51 +07:00
nntrivi2001 ee119fe437 fix(terminal): restamp every glyph-atlas page so no pane keeps a stale texture
v2.8.42 reacted to a page merge by toggling the font family on the next
frame, so each renderer re-acquired an atlas. Panes still showed squeezed,
overlapping glyphs: the toggle lands a frame late, swaps every pane onto a
fresh atlas (or onto one another pane already toggled to), and a page added
without a merge shifts nothing yet still raced it.

addon-webgl 0.20 fixes the root cause with globally monotonic page versions.
Do the same on 0.19: whenever the shared atlas adds, removes or merges a page,
give every page a version no texture slot has uploaded, synchronously, so
every renderer on that atlas re-uploads all pages before it draws again.
When the page comes back from the background, restamp and repaint. An addon
of another shape keeps the old font-toggle fallback.
2026-10-30 10:33:57 +07:00
nntrivi2001 d97c403aab 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).
2026-10-30 10:28:53 +07:00
19 changed files with 452 additions and 85 deletions
+2 -2
View File
File diff suppressed because one or more lines are too long
+4 -2
View File
@@ -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,
@@ -1470,25 +1470,40 @@ export function LaneChangesPanel({
}
};
// Discard all: every non-conflicted file in one `discard`, asked once in a
// dialog that names how many untracked files it will delete.
const discardAll = async () => {
const rows = files.filter((f) => !f.conflicted);
if (!rows.length) return;
// Discard selected: exactly what Commit would take — the ticked files, and
// only the ticked blocks of a partly ticked one (#87: it used to discard
// every file, ticked or not) — in one `discard`, asked once in a dialog
// that names how many untracked files it will delete.
const discardTicked = async () => {
if (!tickedPaths.length) return;
const ok = await confirmDialog({
title: t("changes.discardAllTitle"),
message: t("changes.discardAllConfirm", {
count: rows.length,
untracked: rows.filter((f) => f.untracked).length,
title: t("changes.discardTickedTitle"),
message: t("changes.discardTickedConfirm", {
count: tickedPaths.length,
untracked: files.filter((f) => f.untracked && tickedPaths.includes(f.path)).length,
}),
confirmLabel: t("changes.discardAll"),
confirmLabel: t("changes.discardTicked"),
});
if (ok)
await act(
"discard",
rows.map((f) => f.path),
true
);
if (!ok) return;
const lane = laneId;
setBusy(true);
setError(null);
try {
const { paths, hunks } = await selection();
await api.lanes.gitAction(target, "discard", {
paths,
...(hunks ? { hunks, context: CONTEXT_STEPS[0] } : {}),
confirm: true,
});
if (laneNow.current !== lane) return;
setConfirmDiscard(null);
refreshGit();
await load();
} catch (err) {
if (laneNow.current === lane) setError(err instanceof Error ? err.message : String(err));
} finally {
if (laneNow.current === lane) setBusy(false);
}
};
// resolve / merge-commit / merge-abort: same shape as `act`, other body.
@@ -2067,8 +2082,7 @@ export function LaneChangesPanel({
// An HTML page's relative CSS/scripts/images, read like a download — only
// from the working tree: a commit's page would get today's files.
const htmlFetch = useMemo(
() =>
range === "commit" ? null : fetchVia((p) => api.lanes.gitDownloadUrl(target, p)),
() => (range === "commit" ? null : fetchVia((p) => api.lanes.gitDownloadUrl(target, p))),
// eslint-disable-next-line react-hooks/exhaustive-deps
[range, JSON.stringify(target)]
);
@@ -2863,13 +2877,13 @@ export function LaneChangesPanel({
<button
type="button"
data-testid="lane-changes-discard-all"
disabled={busy}
onClick={() => void discardAll()}
title={t("changes.discardAllTitle")}
disabled={busy || tickedPaths.length === 0}
onClick={() => void discardTicked()}
title={t("changes.discardTickedTitle")}
className="shrink-0 flex items-center gap-1 rounded border border-border-light px-1.5 py-0.5 text-[11px] text-status-danger hover:bg-status-danger/10 disabled:cursor-not-allowed disabled:opacity-40"
>
<Trash2 className="h-3 w-3" aria-hidden />
{t("changes.discardAll")}
{t("changes.discardTicked", { count: tickedPaths.length })}
</button>
</div>
</div>
@@ -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>
@@ -837,10 +837,12 @@ describe("LaneChangesPanel", () => {
});
});
it("discards every file at once only after the dialog is confirmed", async () => {
it("discards only the ticked files, and only after the dialog is confirmed (#87)", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
// notes.txt (unversioned) starts unticked; untick docs/API.md too.
await user.click(tickOf("docs/API.md"));
vi.mocked(confirmDialog).mockResolvedValueOnce(false);
await user.click(screen.getByTestId("lane-changes-discard-all"));
@@ -849,10 +851,14 @@ describe("LaneChangesPanel", () => {
await user.click(screen.getByTestId("lane-changes-discard-all"));
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "discard", {
paths: ["server/lib/git-ops.js", "notes.txt", "docs/API.md"],
paths: ["server/lib/git-ops.js"],
confirm: true,
})
);
// Nothing ticked: nothing to discard.
await user.click(tickOf("server/lib/git-ops.js"));
expect(screen.getByTestId("lane-changes-discard-all")).toBeDisabled();
});
it("right-click on a changed file offers Commit File, a two-click discard, copy path and patch", async () => {
@@ -1673,6 +1679,25 @@ describe("LaneChangesPanel", () => {
expect(api.lanes.gitAction).not.toHaveBeenCalled();
});
it("discards only the ticked change blocks of a partly ticked file (#87)", async () => {
const user = userEvent.setup();
await openReview(user);
await user.click(
screen.getAllByRole("checkbox", { name: "Include this change in the commit" })[1]!
);
await user.click(screen.getByRole("button", { name: "Changes" }));
await screen.findByTestId("lane-changes-commit");
await user.click(screen.getByTestId("lane-changes-discard-all"));
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "discard", {
paths: [],
hunks: { "server/lib/git-ops.js": [{ hunk: "@@ -1,2 +1,3 @@", blocks: [0] }] },
context: 3,
confirm: true,
})
);
});
it("commits only the ticked change blocks of a partly ticked file (hunks)", async () => {
const user = userEvent.setup();
await openReview(user);
@@ -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);
});
});
+52 -12
View File
@@ -96,6 +96,36 @@ type DropState = "hint" | "paste" | "uploading" | "failed" | "tooBig" | "tooBigL
/** localStorage flag for xterm's screen-reader mode (a Settings toggle can set it later). */
export const SCREEN_READER_KEY = "agenthub.terminal.screenReader";
/** localStorage flag: the "Shift+drag selects text" hint was dismissed. */
/** The atlas a WebGL addon draws from: @xterm/addon-webgl 0.19 keeps these
* fields unminified in its build. Undefined on any other shape. */
type AtlasPages = { pages: { version: number }[] };
const atlasOf = (addon: WebglAddon): AtlasPages | undefined => {
const atlas = (addon as unknown as { _renderer?: { _charAtlas?: AtlasPages } })._renderer
?._charAtlas;
return Array.isArray(atlas?.pages) ? atlas : undefined;
};
/** Last version handed out by `restampAtlas`, shared by every pane. */
let atlasStamp = 0;
/**
* Gives every page of the addon's glyph atlas a version no texture slot has
* uploaded, so EVERY renderer sharing that atlas re-uploads every page from
* its canvas on its next draw — what addon-webgl 0.20 does with globally
* monotonic page versions. 0.19 counts per page, so after a merge shifts the
* pages down a slot can hold a stale page whose counter happens to match, and
* glyphs are sampled squeezed or foreign. Steps of 2^24 stay clear of the
* per-glyph increments a page makes before the next restamp. False when the
* addon is not the shape this knows.
*/
const restampAtlas = (addon: WebglAddon): boolean => {
const atlas = atlasOf(addon);
if (!atlas) return false;
for (const page of atlas.pages) {
atlasStamp += 2 ** 24;
page.version = atlasStamp;
}
return true;
};
const SELECT_HINT_KEY = "agenthub.terminal.selectHintSeen";
const readFlag = (key: string) => {
try {
@@ -763,31 +793,40 @@ export const TerminalView = memo(function TerminalView({
};
document.fonts?.addEventListener("loadingdone", onFontLoaded);
// @xterm/addon-webgl 0.19 shares ONE glyph atlas between every terminal
// with the same font, and when it fills up it merges four pages into one
// larger page and shifts the rest down. A GPU texture is re-uploaded only
// when its page's `version` differs from the uploaded one, and after a
// shift those counters can coincide, so a slot keeps a stale page of
// another size: every glyph is sampled squeezed or foreign while the
// buffer stays correct (fixed upstream only in 0.20 betas). Any option
// change makes each renderer re-acquire its atlas and re-upload all pages,
// so this toggles the family's trailing space (as onFontLoaded does) on
// a merge. `clearTextureAtlas` is no fix: it empties the SHARED atlas
// with the same font. When it fills up it merges four pages into one
// larger page and shifts the rest down, and a GPU texture is re-uploaded
// only when its page's `version` differs from the uploaded one — after a
// shift those per-page counters can coincide, so a slot keeps a stale
// page of another size and every glyph is drawn squeezed or foreign
// while the buffer stays correct (fixed upstream only in 0.20 betas).
// Any change to the page list therefore restamps every page's version
// (`restampAtlas`) synchronously, before the next draw of ANY pane on
// that atlas. `clearTextureAtlas` is no fix: it empties the SHARED atlas
// but redraws only this pane, leaving its siblings slicing reused space.
// An addon of another shape falls back to toggling the family's
// trailing space on the next frame, which makes each renderer re-acquire
// its atlas (as onFontLoaded does).
let atlasFrame = 0;
const rebuildAtlas = () => {
if (atlasFrame) return;
if (gpu && restampAtlas(gpu)) return true;
if (atlasFrame) return false;
// Never inside the render that is rasterizing the merging glyph.
atlasFrame = requestAnimationFrame(() => {
atlasFrame = 0;
const family = term.options.fontFamily ?? "";
term.options.fontFamily = family.endsWith(" ") ? family.trimEnd() : `${family} `;
});
return false;
};
const atlasMerged = gpu?.onRemoveTextureAtlasCanvas(rebuildAtlas);
const atlasGrew = gpu?.onAddTextureAtlasCanvas(rebuildAtlas);
// The browser may also drop the textures without a context-loss event
// (sleep/resume, a long-backgrounded tab): rebuild when the page is back.
// (sleep/resume, a long-backgrounded tab): re-upload when the page is
// back, and repaint, since nothing else asks this pane to draw.
const onPageShown = () => {
if (document.visibilityState === "visible") rebuildAtlas();
if (document.visibilityState === "visible" && rebuildAtlas()) {
term.refresh(0, term.rows - 1);
}
};
document.addEventListener("visibilitychange", onPageShown);
window.addEventListener("focus", onPageShown);
@@ -811,6 +850,7 @@ export const TerminalView = memo(function TerminalView({
document.fonts?.removeEventListener("loadingdone", onFontLoaded);
if (atlasFrame) cancelAnimationFrame(atlasFrame);
atlasMerged?.dispose();
atlasGrew?.dispose();
document.removeEventListener("visibilitychange", onPageShown);
window.removeEventListener("focus", onPageShown);
window.removeEventListener(FONT_SIZE_EVENT, onFontPref);
@@ -63,6 +63,9 @@ const termState = {
};
const onResizeHandlers: Array<() => void> = [];
const termOptions: { theme?: unknown } = {};
const refreshMock = vi.fn();
/** The atlas the next WebGL addon draws from; null = an addon of another shape. */
const nextAtlas: { pages: { version: number }[] | null } = { pages: null };
const ctorArgs: Array<{ theme?: unknown }> = [];
vi.mock("@xterm/xterm", () => ({
@@ -92,6 +95,7 @@ vi.mock("@xterm/xterm", () => ({
return { dispose: vi.fn() };
},
dispose: disposeMock,
refresh: refreshMock,
loadAddon: loadAddonMock,
attachCustomWheelEventHandler: wheelHandlerMock,
attachCustomKeyEventHandler: keyHandlerMock,
@@ -113,7 +117,12 @@ vi.mock("@xterm/addon-fit", () => ({
}));
// The newest WebGL addon, with the context-loss listener the view gave it.
const gpu: {
last: { dispose: ReturnType<typeof vi.fn>; lose: () => void; merge: () => void } | null;
last: {
dispose: ReturnType<typeof vi.fn>;
lose: () => void;
merge: () => void;
grow: () => void;
} | null;
} = {
last: null,
};
@@ -121,8 +130,10 @@ vi.mock("@xterm/addon-webgl", () => ({
WebglAddon: vi.fn().mockImplementation(() => {
let onLoss = () => {};
let onMerge = () => {};
let onGrow = () => {};
const addon = {
__webgl: true,
...(nextAtlas.pages ? { _renderer: { _charAtlas: { pages: nextAtlas.pages } } } : {}),
dispose: vi.fn(),
onContextLoss: (fn: () => void) => {
onLoss = fn;
@@ -132,8 +143,17 @@ vi.mock("@xterm/addon-webgl", () => ({
onMerge = fn;
return { dispose: vi.fn() };
},
onAddTextureAtlasCanvas: (fn: () => void) => {
onGrow = fn;
return { dispose: vi.fn() };
},
};
gpu.last = {
dispose: addon.dispose,
lose: () => onLoss(),
merge: () => onMerge(),
grow: () => onGrow(),
};
gpu.last = { dispose: addon.dispose, lose: () => onLoss(), merge: () => onMerge() };
return addon;
}),
}));
@@ -495,9 +515,37 @@ describe("TerminalView", () => {
expect(disposeMock).not.toHaveBeenCalled(); // the terminal itself lives on
});
it("restamps every atlas page at once when the page list changes", () => {
// addon-webgl 0.19 counts versions per page, so after a merge shifts the
// pages a texture slot can keep a stale page whose counter matches. Every
// page gets a version no slot has uploaded — in every pane on the atlas.
const pages = [{ version: 3 }, { version: 7 }, { version: 1 }];
nextAtlas.pages = pages;
const raf = vi.spyOn(window, "requestAnimationFrame");
try {
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
gpu.last!.merge();
const first = pages.map((p) => p.version);
expect(new Set(first).size).toBe(3);
expect(first.every((v) => v > 7)).toBe(true);
gpu.last!.grow();
expect(pages.every((p, i) => p.version > Math.max(...first) && p.version !== first[i])).toBe(
true
);
expect(raf).not.toHaveBeenCalled(); // synchronous, before any pane draws
// The page coming back re-uploads and repaints this pane.
refreshMock.mockClear();
window.dispatchEvent(new Event("focus"));
expect(refreshMock).toHaveBeenCalledWith(0, 27);
} finally {
nextAtlas.pages = null;
raf.mockRestore();
}
});
it("re-acquires the glyph atlas on the next frame after a page merge", () => {
// addon-webgl 0.19 may keep a stale page in a GPU slot after it merges
// atlas pages; any option change makes it re-upload every page.
// An addon whose atlas this cannot reach: any option change makes each
// renderer re-acquire its atlas and re-upload every page.
const raf = vi.spyOn(window, "requestAnimationFrame").mockImplementation(() => 1);
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
const opts = termOptions as { fontFamily?: string };
+4 -3
View File
@@ -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",
@@ -531,9 +532,9 @@
"changes.unversionedGroup": "Unversioned Files",
"changes.commitTicked": "Commit ({{count}})",
"changes.commitAndPush": "Commit and Push",
"changes.discardAll": "Discard all",
"changes.discardAllTitle": "Discard all changes?",
"changes.discardAllConfirm": "Every change in {{count}} file(s) goes back to the last commit, and {{untracked}} new file(s) git does not track yet are deleted. This cannot be undone.",
"changes.discardTicked": "Discard ({{count}})",
"changes.discardTickedTitle": "Discard the ticked changes?",
"changes.discardTickedConfirm": "The ticked changes in {{count}} file(s) go back to the last commit, and {{untracked}} ticked new file(s) git does not track yet are deleted. Unticked files and blocks are kept. This cannot be undone.",
"changes.amendAndPush": "Amend and Push",
"changes.pushFailed": "Committed, but the push failed: {{error}}",
"changes.tickFile": "Include {{path}} in the commit",
+4 -3
View File
@@ -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",
@@ -531,9 +532,9 @@
"changes.unversionedGroup": "File chưa theo dõi",
"changes.commitTicked": "Commit ({{count}})",
"changes.commitAndPush": "Commit và Push",
"changes.discardAll": "Bỏ tất cả",
"changes.discardAllTitle": "Bỏ tất cả thay đổi?",
"changes.discardAllConfirm": "Mọi thay đổi trong {{count}} file sẽ trở về commit cuối, và {{untracked}} file mới chưa được git theo dõi sẽ bị xoá. Không thể hoàn tác.",
"changes.discardTicked": "Bỏ ({{count}})",
"changes.discardTickedTitle": "Bỏ các thay đổi đã chọn?",
"changes.discardTickedConfirm": "Các thay đổi đã chọn trong {{count}} file sẽ trở về commit cuối, và {{untracked}} file mới đã chọn chưa được git theo dõi sẽ bị xoá. File và đoạn không chọn được giữ nguyên. Không thể hoàn tác.",
"changes.amendAndPush": "Amend và Push",
"changes.pushFailed": "Đã commit, nhưng push thất bại: {{error}}",
"changes.tickFile": "Đưa {{path}} vào commit",
+3
View File
@@ -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
@@ -7316,7 +7316,7 @@ exports[`screen snapshots > Settings — about 1`] = `
<span
class="text-status-warning/90"
>
UI build v2.8.42 (rebuild client to match)
UI build v2.8.44 (rebuild client to match)
</span>
</p>
</div>
+6 -1
View File
@@ -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`)
@@ -1058,7 +1063,7 @@ cannot collide on `index.lock`.
| `push` | `git push -u <remote> HEAD` — the branch's upstream remote, else the only remote, else `origin`, else `400 ENOREMOTE` | — |
| `stage` | `git add -- <paths>` | `{"paths": ["..."]}` |
| `unstage` | `git restore --staged -- <paths>` (a staged rename also passes its original path, so both halves unstage) | `{"paths": ["..."]}` |
| `discard` | `git restore --source=HEAD --staged --worktree` (tracked) / `git clean -fd` (untracked) | `{"paths": ["..."], "confirm": true}` |
| `discard` | `git restore --source=HEAD --staged --worktree` (tracked) / `git clean -fd` (untracked); with `hunks` (a partly ticked file's ticked change blocks, named as in `commit`), `git apply -R` of only those blocks off the working tree first — every patch built before any is applied, so a stale one is `409 EHUNKGONE` with nothing undone | `{"paths": ["..."], "confirm": true}` / `{"paths": [], "hunks": {"<path>": [{"hunk": "@@ … @@", "blocks": [0]}]}, "context": 3, "confirm": true}` |
| `stage-hunk` | `git apply --cached` of ONE hunk of `git diff -U<context> HEAD -- <path>` — or, with `block`, of only that hunk's `block`-th run of +/- lines (the other runs folded into context, as an edited `git add -p` hunk) | `{"path": "...", "hunk": "@@ -1,2 +1,3 @@", "block": 0, "context": 3}` |
| `discard-hunk` | `git apply -R` of that one hunk (or that one `block` of it), off the working tree | `{"path": "...", "hunk": "@@ ... @@", "block": 0, "context": 3, "confirm": true}` |
| `commit` | `git add -A` then `git commit -m <message>`, or the index as it stands with `only: "staged"`, or — with `paths` — `git commit --only -- <paths>`: exactly those files' working-tree content (an untracked one is added first, and taken back out if the commit fails), every other change left as it was | `{"message": "...", "only": "staged"}` / `{"message": "...", "paths": ["..."]}` |
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "agenthub",
"version": "2.8.42",
"version": "2.8.44",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "agenthub",
"version": "2.8.42",
"version": "2.8.44",
"hasInstallScript": true,
"license": "UNLICENSED",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "agenthub",
"version": "2.8.42",
"version": "2.8.44",
"bin": {
"agenthub": "bin/agenthub.js"
},
@@ -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"}}';
+56 -8
View File
@@ -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 () => {
@@ -848,6 +851,51 @@ describe("runGitAction — one change block inside a hunk (issue #50)", () => {
assert.equal(fs.readFileSync(path.join(LANE, "tail.txt"), "utf8"), "l1\nl2\nl3\nl4\nl5\nl6");
});
it("discards only the ticked blocks and whole files, nothing unticked (issue #87)", async () => {
const edited = threeBlocks();
write(LANE, "keep.txt", "untouched by discard\n");
write(LANE, "drop.txt", "gone\n");
const [hunk] = await hunkOf();
await assert.rejects(
() =>
runGitAction(LANE, "discard", {
paths: ["drop.txt"],
hunks: { "near.txt": [{ hunk: hunk.header, blocks: [0, 2] }] },
}),
{ code: "ECONFIRM" }
);
await runGitAction(LANE, "discard", {
paths: ["drop.txt"],
hunks: { "near.txt": [{ hunk: hunk.header, blocks: [0, 2] }] },
confirm: true,
});
const want = [...edited];
want.splice(11, 0, "line 11");
want.splice(3, 2, "line 4", "line 5");
assert.deepEqual(read(), [...want, ""]);
assert.equal(fs.existsSync(path.join(LANE, "drop.txt")), false);
assert.equal(fs.readFileSync(path.join(LANE, "keep.txt"), "utf8"), "untouched by discard\n");
});
it("discards ticked blocks alone, and refuses a stale one with nothing undone", async () => {
const edited = threeBlocks();
const [hunk] = await hunkOf();
await assert.rejects(
() =>
runGitAction(LANE, "discard", {
hunks: { "near.txt": [{ hunk: "@@ -1,2 +1,2 @@", blocks: [0] }] },
confirm: true,
}),
{ code: "EHUNKGONE" }
);
assert.deepEqual(read(), [...edited, ""]);
await runGitAction(LANE, "discard", {
hunks: { "near.txt": [{ hunk: hunk.header, blocks: [1] }] },
confirm: true,
});
assert.deepEqual(read(), [...edited.filter((l) => l !== "INSERTED"), ""]);
});
it("refuses a block the hunk does not have", async () => {
threeBlocks();
const [hunk] = await hunkOf();
+60 -8
View File
@@ -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
@@ -1192,16 +1241,18 @@ async function diffHunks(dir, file, context) {
* in file order, to be applied FORWARD onto an index holding HEAD. Every
* header is matched against a fresh diff like `hunkPatch`'s; null when one is
* gone or names a block the hunk lacks. Each hunk's new-side start is left as
* git wrote it — `git apply` locates a hunk by its old side.
* git wrote it — `git apply` locates a hunk by its old side. `reverse` cuts
* it for `apply -R` off the working tree instead (the partial discard, #87),
* where the NEW side is the one checked and every start stays git's own.
*/
async function selectionPatch(dir, file, context, picks) {
async function selectionPatch(dir, file, context, picks, reverse = false) {
const diff = await diffHunks(dir, file, context);
if (!diff || !picks.length) return null;
const at = (header) => diff.hunks.findIndex((h) => h.slice(0, h.indexOf("\n")) === header);
if (picks.some((p) => at(p.hunk) < 0)) return null;
const bodies = [];
for (const p of [...picks].sort((x, y) => at(x.hunk) - at(y.hunk))) {
const body = onlyBlock(diff.hunks[at(p.hunk)], p.blocks, false);
const body = onlyBlock(diff.hunks[at(p.hunk)], p.blocks, reverse);
if (!body) return null;
bodies.push(body);
}
@@ -1334,6 +1385,7 @@ async function plainTreeAll(dir) {
}
module.exports = {
elideLongLines,
collectChanges,
CONFLICT_CODES,
contentSha,
+56 -14
View File
@@ -424,6 +424,13 @@ async function runGitAction(dir, action, body = {}) {
if (action === "discard") {
if (body.confirm !== true) throw bad("ECONFIRM", "discard requires confirm: true");
// Ticked change blocks of partly ticked files (#87) go first: each patch
// is built before any is applied, so a stale one refuses before the
// irreversible whole-file part below has run.
const partial = hasHunks(body) ? await discardSelection(dir, body) : null;
if (partial && !(Array.isArray(body.paths) && body.paths.length)) {
return done(action, partial);
}
const paths = await resolvePaths(dir, body);
const untracked = paths.filter((p) => p.code === "??").map((p) => p.path);
// A rename's ORIGINAL path rides along: restoring only the new one would
@@ -431,7 +438,7 @@ async function runGitAction(dir, action, body = {}) {
const tracked = paths
.filter((p) => p.code !== "??")
.flatMap((p) => (p.from && p.code.includes("R") ? [p.path, p.from] : [p.path]));
const out = [];
const out = partial ? [partial] : [];
// Untracked files are deleted (git has no copy to restore); tracked ones
// go back to HEAD in both gutters, so a staged-then-edited file lands in
// one state rather than half-reverted.
@@ -825,18 +832,11 @@ function hasHunks(body) {
const MAX_PARTIAL_FILES = 300;
/**
* JetBrains' partial commit: whole files (`paths`) plus, per file in `hunks`,
* only the ticked change blocks — `{"<path>": [{hunk: "@@ … @@", blocks: [0]}]}`,
* the hunks named as `stage-hunk` names them, at `context`. Built in a
* TEMPORARY index (HEAD + the whole files + one patch per partial file from
* `selectionPatch`, git's own fresh text, never the request's) and committed
* from it, so the real index and the working tree are never in the way; after
* the commit the real index entries of exactly those paths are reset to the
* new HEAD, the rest of each partial file left as an unstaged change. Same
* path rules as `commitPaths`; a partial file must be tracked (EUNTRACKED)
* and not a rename, and a hunk the tree no longer has is EHUNKGONE.
* `body.hunks` checked for shape, and its files resolved as changed paths
* that may be taken in part: tracked (EUNTRACKED), not a rename, not in
* conflict, and not also named whole in `body.paths`.
*/
async function commitSelection(dir, args, body) {
async function partialPicks(dir, body, verb) {
const picks = Object.entries(body.hunks);
if (picks.length > MAX_PARTIAL_FILES) throw bad("EBADPATH", "too many files");
for (const [, list] of picks) {
@@ -858,12 +858,54 @@ async function commitSelection(dir, args, body) {
const conflicted = [...whole, ...part].find((p) => CONFLICT_CODES.has(p.code));
if (conflicted) throw bad("ECONFLICTS", `${conflicted.path} is in conflict: resolve it first`);
for (const p of part) {
if (p.code === "??") throw bad("EUNTRACKED", `${p.path} is untracked: commit it whole`);
if (p.code.includes("R")) throw bad("EBADPATH", `${p.path} is a rename: commit it whole`);
if (p.code === "??") throw bad("EUNTRACKED", `${p.path} is untracked: ${verb} it whole`);
if (p.code.includes("R")) throw bad("EBADPATH", `${p.path} is a rename: ${verb} it whole`);
if (whole.some((w) => w.path === p.path)) {
throw bad("EBADPATH", `${p.path} is named both whole and in part`);
}
}
return { picks, whole, part };
}
/**
* The partial discard (#87): per file in `hunks`, only the ticked change
* blocks are undone off the working tree — `selectionPatch` in reverse, git's
* own fresh text, never the request's — exactly what one `discard-hunk` per
* block would do, in one step. Every patch is built before the first is
* applied, so a stale selection refuses (EHUNKGONE) with nothing undone.
*/
async function discardSelection(dir, body) {
const { picks } = await partialPicks(dir, body, "discard");
const patches = [];
for (const [p, list] of picks) {
const patch = await selectionPatch(dir, p, body.context, list, true);
if (!patch)
throw bad("EHUNKGONE", `${p}: that change is no longer in the diff; refresh and retry`);
patches.push(patch);
}
// The patch names paths from the repo root, so it is applied there.
const top = (await git(dir, ["rev-parse", "--show-toplevel"])).stdout.trim();
const zero = Number(body.context) === 0 ? ["--unidiff-zero"] : [];
const out = [];
for (const patch of patches)
out.push(await git(top, ["apply", "-R", ...zero, "-"], { input: patch }));
return mergeResults(out);
}
/**
* JetBrains' partial commit: whole files (`paths`) plus, per file in `hunks`,
* only the ticked change blocks — `{"<path>": [{hunk: "@@ … @@", blocks: [0]}]}`,
* the hunks named as `stage-hunk` names them, at `context`. Built in a
* TEMPORARY index (HEAD + the whole files + one patch per partial file from
* `selectionPatch`, git's own fresh text, never the request's) and committed
* from it, so the real index and the working tree are never in the way; after
* the commit the real index entries of exactly those paths are reset to the
* new HEAD, the rest of each partial file left as an unstaged change. Same
* path rules as `commitPaths`; a partial file must be tracked (EUNTRACKED)
* and not a rename, and a hunk the tree no longer has is EHUNKGONE.
*/
async function commitSelection(dir, args, body) {
const { picks, whole, part } = await partialPicks(dir, body, "commit");
const wholePaths = whole.flatMap((p) =>
p.from && p.code.includes("R") ? [p.path, p.from] : [p.path]
);