Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
ceeafba
fix(desktop): minimize inline comments to one chip per line, as stage…
dastratakos Oct 19, 2026
92655cb
Merge branch 'main' into luke/files-changed-minimize-threads
dastratakos Oct 19, 2026
b5b312d
fix(desktop): keep a minimized line's fold mounted, and give threads …
dastratakos Oct 19, 2026
6eafcab
style(desktop): Files changed as cards that share one edge, with a qu…
dastratakos Oct 19, 2026
985048e
feat(desktop): copy a file's path from its header, fold it from anywh…
dastratakos Oct 19, 2026
e454bc1
Merge branch 'main' into luke/files-changed-minimize-threads
dastratakos Oct 19, 2026
c20be68
Merge branch 'luke/files-changed-minimize-threads' into luke/files-ch…
dastratakos Oct 19, 2026
e9a956b
Merge branch 'luke/files-changed-design' into luke/files-changed-head…
dastratakos Oct 19, 2026
5ea9b05
feat(desktop): expand a whole file from its header, and show a file t…
dastratakos Oct 19, 2026
c2af840
feat(desktop): offer Show file on a file that only moved
dastratakos Oct 19, 2026
c1e257c
Merge main into luke/files-changed-minimize-threads
dastratakos Oct 19, 2026
c415bbb
Merge luke/files-changed-minimize-threads into luke/files-changed-design
dastratakos Oct 19, 2026
eab2610
Merge luke/files-changed-design into luke/files-changed-header-actions
dastratakos Oct 19, 2026
6afbb79
Merge luke/files-changed-header-actions into luke/files-changed-whole…
dastratakos Oct 19, 2026
b655273
Merge main into luke/files-changed-whole-file
dastratakos Oct 19, 2026
75fe6ab
Merge main into luke/files-changed-whole-file
dastratakos Oct 19, 2026
60d55e9
Merge main into luke/files-changed-whole-file
dastratakos Oct 19, 2026
674689b
fix(desktop): draw a file that only moved whole as its lines, place i…
dastratakos Oct 19, 2026
d07aeb0
fix(desktop): an empty file that only moved offers no Show file once …
dastratakos Oct 19, 2026
22ac644
Merge main into luke/files-changed-whole-file
dastratakos Oct 19, 2026
0710b5c
Merge main into luke/files-changed-whole-file
dastratakos Oct 19, 2026
b3d1aa7
Merge main into luke/files-changed-whole-file
dastratakos Oct 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/panel/src/diff-view/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,8 @@ export interface DiffListFile {
readonly lead: boolean;
/** Whether the lines between the file's hunks may be opened, its text read whole the first time one is (`DiffListProps.readTexts`). */
readonly expandable: boolean;
/** The file's whole text before and after, where it is drawn whole, every unchanged line among its changes; none draws its hunks. */
readonly whole?: DiffFileTexts | undefined;
}

/** Lines lit in one file of a list: the file, by its path, and the lines. */
Expand Down
11 changes: 9 additions & 2 deletions packages/panel/src/diff-view/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
import { type Highlighting, startHighlighting } from "./highlight-pool.js";
import { adoptShadowStyles } from "./inline-styles.js";
import { pressedOnCode, selectedLines, selectionRect } from "./text-selection.js";
import { wholeOf } from "./whole-diff.js";

/**
* index.ts -- the diff bundle: a patch drawn by Pierre's diffs as every diff setting says, in the panel's colours.
Expand Down Expand Up @@ -694,16 +695,22 @@ function mountList(host: HTMLElement, props: DiffListProps): DiffListHandle {
held.file.lead === file.lead &&
held.file.collapsed === file.collapsed &&
held.file.expandable === file.expandable &&
held.file.whole === file.whole &&
sameNotes(held.file.annotations, file.annotations)
) {
return held.item;
}
const read =
held?.file.patch === file.patch && held.file.lead === file.lead ? held.read : patchOf(file);
held?.file.patch === file.patch &&
held.file.lead === file.lead &&
held.file.whole === undefined
? held.read
: patchOf(file);
const item: CodeViewDiffItem<string> = {
id: file.path,
type: "diff",
fileDiff: drawnAs(read, file.expandable),
fileDiff:
file.whole === undefined ? drawnAs(read, file.expandable) : wholeOf(read, file.whole),
collapsed: file.collapsed,
annotations: annotationsOf(file.annotations),
version: (held?.item.version ?? 0) + 1,
Expand Down
55 changes: 55 additions & 0 deletions packages/panel/src/diff-view/whole-diff.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/**
* whole-diff.test.ts -- a file drawn whole is one hunk holding every line,
* its changes among them, and a file that only moved, its texts alike, is
* every one of its lines as context rather than no hunk at all. Synthetic
* files only.
*/

import assert from "node:assert/strict";
import { parsePatchFiles } from "@pierre/diffs";
import { test } from "vitest";
import { wholeOf } from "./whole-diff.js";

/** A patch as the list reads it, of one file named as given. */
function read(patch: string) {
const file = parsePatchFiles(patch)[0]?.files[0];
return file ?? assert.fail("the patch reads");
}

test("a changed file drawn whole is one hunk of every line, its change among them", () => {
const drawn = wholeOf(
read("diff --git a/a.ts b/a.ts\n--- a/a.ts\n+++ b/a.ts\n@@ -2,1 +2,1 @@\n-b\n+B\n"),
{ before: "a\nb\nc\n", after: "a\nB\nc\n" },
);
assert.equal(drawn.hunks.length, 1);
assert.equal(drawn.hunks[0]?.additionStart, 1);
assert.equal(drawn.hunks[0]?.additionCount, 3);
});

test("a file that only moved, drawn whole, is every one of its lines as context, numbered alike on both sides", () => {
const moved = read(
"diff --git a/old.ts b/new.ts\nsimilarity index 100%\nrename from old.ts\nrename to new.ts\n",
);
const texts = { before: "a\nb\nc\n", after: "a\nb\nc\n" };
const drawn = wholeOf(moved, texts);

assert.equal(drawn.hunks.length, 1);
const hunk = drawn.hunks[0] ?? assert.fail();
assert.deepEqual(
[hunk.additionStart, hunk.additionCount, hunk.deletionStart, hunk.deletionCount],
[1, 3, 1, 3],
);
assert.deepEqual(hunk.hunkContent, [
{ type: "context", lines: 3, additionLineIndex: 0, deletionLineIndex: 0 },
]);
assert.equal(drawn.unifiedLineCount, 3);
assert.equal(drawn.prevName, "old.ts");
assert.equal(wholeOf(moved, texts), drawn, "made once for its texts");
});

test("a file of no line that only moved stays without a hunk", () => {
const moved = read(
"diff --git a/old.ts b/new.ts\nsimilarity index 100%\nrename from old.ts\nrename to new.ts\n",
);
assert.equal(wholeOf(moved, { before: "", after: "" }).hunks.length, 0);
});
60 changes: 60 additions & 0 deletions packages/panel/src/diff-view/whole-diff.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* whole-diff.ts -- a Files changed file drawn whole, as stage-cli's Expand full file draws one: its texts before and after diffed as one hunk holding every line.
*
* Pierre diffs two texts to the hunks around their changes; with every
* line kept as context those hunks are one, the whole file. Two texts alike,
* a file that only moved, diff to no hunk at all, so that file's lines are
* laid out here as one hunk of context, numbered alike on both sides, and
* a file of no line stays as it was parsed.
*/

import { type FileDiffMetadata, type Hunk, parseDiffFromFile } from "@pierre/diffs";
import type { DiffFileTexts } from "./contract.js";

/**
* A file drawn whole: its texts before and after diffed with every line
* kept as context, so its changes stand among all of its unchanged lines
* in one hunk, as a file opened in full; named as its patch names it.
*/
export function wholeOf(read: FileDiffMetadata, whole: DiffFileTexts): FileDiffMetadata {
const kept = WHOLE_DIFFS.get(whole);
if (kept !== undefined) return kept;
const parsed = parseDiffFromFile(
{ name: read.prevName ?? read.name, contents: whole.before },
{ name: read.name, contents: whole.after },
{ context: Number.MAX_SAFE_INTEGER },
);
// Note that texts alike, a file that only moved, diff to no hunk at all, so its lines are laid out as one of context.
const drawn = parsed.hunks.length === 0 ? unchangedWhole(parsed) : parsed;
WHOLE_DIFFS.set(whole, drawn);
return drawn;
}

/** A file whose texts are alike drawn whole: every one of its lines in one hunk of context, numbered alike on both sides. */
function unchangedWhole(parsed: FileDiffMetadata): FileDiffMetadata {
const lines = parsed.additionLines.length;
if (lines === 0) return parsed;
const hunk: Hunk = {
collapsedBefore: 0,
additionStart: 1,
additionCount: lines,
additionLines: 0,
additionLineIndex: 0,
deletionStart: 1,
deletionCount: lines,
deletionLines: 0,
deletionLineIndex: 0,
hunkContent: [{ type: "context", lines, additionLineIndex: 0, deletionLineIndex: 0 }],
hunkSpecs: `@@ -1,${lines} +1,${lines} @@\n`,
splitLineStart: 0,
splitLineCount: lines,
unifiedLineStart: 0,
unifiedLineCount: lines,
noEOFCRDeletions: false,
noEOFCRAdditions: false,
};
return { ...parsed, hunks: [hunk], splitLineCount: lines, unifiedLineCount: lines };
}

/** Each whole file's diff, made once for its texts, so a file drawn again for its notes keeps the diff Pierre drew. */
const WHOLE_DIFFS = new WeakMap<DiffFileTexts, FileDiffMetadata>();
164 changes: 164 additions & 0 deletions packages/panel/src/tasks/agent-changes-tab.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2019,3 +2019,167 @@ test("a file read whole that is not the contents GitHub listed its patch against
assert.deepEqual(await readTexts("src/seats.ts"), { before: BASE_TEXT, after: HEAD_TEXT });
assert.equal(await readTexts("src/moved.ts"), undefined);
});

test("a file that only moved, shown whole, draws its review threads on their lines, and one of no line keeps saying it moved", async () => {
commits = { headSha: "a".repeat(40) };
files = [
changed("src/moved.ts", {
status: CHANGED_FILE_STATUS.RENAMED,
previousPath: "src/old.ts",
patch: "",
}),
changed("src/empty.ts", {
status: CHANGED_FILE_STATUS.RENAMED,
previousPath: "src/was-empty.ts",
patch: "",
}),
];
answerFileReads((path) => ({
text: {
path,
repository: "acme/relay",
branch: "a".repeat(40),
text: path === "src/empty.ts" ? "" : HEAD_TEXT,
},
}));
const threads: ChangesThreads = {
list: [{ ...seatsThread(2, "on-2"), path: "src/moved.ts" }],
draw: (thread) => createElement("p", null, thread.comments[0]?.body),
};
await drawTab({ threads });
const drawnAs = (path: string) => lastDrawn().files.find((file) => file.path === path);
assert.deepEqual(drawnAs("src/moved.ts")?.annotations, [
{ key: "lead", side: "additions", line: 0 },
]);

for (const path of ["src/moved.ts", "src/empty.ts"]) {
const lead = lastDrawn().renderAnnotation(path, "lead");
document.body.append(lead);
await flush();
const show = lead.querySelector<HTMLButtonElement>(`button[aria-label="Show file: ${path}"]`);
act(() => show?.click());
await flush();
}

assert.deepEqual(drawnAs("src/moved.ts")?.annotations, [
{ key: `${PAGE}#on-2`, side: "additions", line: 2 },
]);
assert.equal(drawnAs("src/empty.ts")?.lead, true, "a file of no line says it moved still");
assert.equal(
lastDrawn()
.renderAnnotation("src/empty.ts", "lead")
.querySelector('button[aria-label^="Show file"]'),
null,
"with nothing more to show",
);
});

test("a whole file read for an earlier answer, landing after a refresh, leaves the files expanded since as they are", async () => {
commits = { headSha: "a".repeat(40) };
files = [
changed("src/seats.ts", { patch: HEAD_PATCH }),
changed("src/plan.ts", { patch: HEAD_PATCH }),
];
const held: Array<() => void> = [];
fakeAppBridge({
act: (request: Act) => {
const value =
request.kind === ACT_KIND.TASKS_FILE
? {
text: {
path: request.payload.path,
repository: "acme/relay",
branch: "a".repeat(40),
text: HEAD_TEXT,
},
}
: undefined;
const outcome: ActOutcome = { status: ACT_OUTCOME_STATUS.DONE, value };
// The first file's read is held until the test lets it land.
if (request.kind === ACT_KIND.TASKS_FILE && request.payload.path === "src/seats.ts") {
return new Promise((resolve) => held.push(() => resolve(outcome)));
}
return Promise.resolve(outcome);
},
});
handSettings();
await drawTab();
const expand = (path: string) =>
[...header(path).querySelectorAll<HTMLButtonElement>("button")].find(
(button) => button.getAttribute("aria-label") === "Expand full file",
);

act(() => expand("src/seats.ts")?.click());
await flush();
const options = document.querySelector<HTMLButtonElement>('button[aria-label="Changes actions"]');
act(() => options?.click());
const refresh = [...document.querySelectorAll<HTMLButtonElement>('[role="menuitem"]')].find(
(each) => each.textContent?.includes("Refresh changes"),
);
act(() => refresh?.click());
await flush();
act(() => expand("src/plan.ts")?.click());
await flush();
assert.notEqual(lastDrawn().files.find((file) => file.path === "src/plan.ts")?.whole, undefined);

for (const land of held) land();
await flush();

const whole = (path: string) => lastDrawn().files.find((file) => file.path === path)?.whole;
assert.notEqual(whole("src/plan.ts"), undefined, "the file expanded since stays whole");
assert.equal(whole("src/seats.ts"), undefined, "and the earlier answer's read is dropped");
});

test("a file's header draws the whole file on Expand full file, read once, and back to its changes; a file that only moved shows its lines, from Show file, rather than saying so", async () => {
commits = { headSha: "a".repeat(40) };
files = [
changed("src/seats.ts", { patch: HEAD_PATCH }),
changed("src/moved.ts", {
status: CHANGED_FILE_STATUS.RENAMED,
previousPath: "src/old.ts",
patch: "",
}),
changed("src/new.ts", { status: CHANGED_FILE_STATUS.ADDED }),
];
const asked = answerFileReads((path) => ({
text: { path, repository: "acme/relay", branch: "a".repeat(40), text: HEAD_TEXT },
}));
await drawTab();
const toggle = (path: string) =>
[...header(path).querySelectorAll<HTMLButtonElement>("button")].find((button) =>
["Expand full file", "Collapse to changes"].includes(button.getAttribute("aria-label") ?? ""),
);
assert.equal(toggle("src/new.ts"), undefined, "an added file has nothing to expand");
const drawnAs = (path: string) => lastDrawn().files.find((file) => file.path === path);
assert.equal(drawnAs("src/moved.ts")?.lead, true, "a file that only moved says so");

act(() => toggle("src/seats.ts")?.click());
await flush();
assert.deepEqual(drawnAs("src/seats.ts")?.whole, { before: BASE_TEXT, after: HEAD_TEXT });
const lead = lastDrawn().renderAnnotation("src/moved.ts", "lead");
document.body.append(lead);
await flush();
const showFile = lead.querySelector<HTMLButtonElement>(
'button[aria-label="Show file: src/moved.ts"]',
);
assert.ok(showFile, "what a file that only moved says offers its lines");
act(() => showFile.click());
await flush();
assert.deepEqual(drawnAs("src/moved.ts")?.whole, { before: HEAD_TEXT, after: HEAD_TEXT });
assert.equal(drawnAs("src/moved.ts")?.lead, false, "and once whole, shows its lines");

act(() => toggle("src/seats.ts")?.click());
await flush();
assert.equal(
drawnAs("src/seats.ts")?.whole,
undefined,
"Collapse to changes draws its hunks again",
);
act(() => toggle("src/seats.ts")?.click());
await flush();
assert.equal(
asked.filter((request) => request.kind === ACT_KIND.TASKS_FILE).length,
2,
"each file is read once",
);
});
Loading
Loading