feat(lanes): stage/undo one hunk at a time, sticky review file headers

Review and the single-file view gain per-hunk Stage and Undo
(stage-hunk / discard-hunk: the hunk is named by its @@ line and
re-matched against a fresh diff, then git apply --cached / -R; undo
needs confirm: true and two clicks). Each review file header stays
pinned while its diff scrolls and carries the file's own
stage/unstage/discard.
This commit is contained in:
2026-09-24 14:38:48 +07:00
parent 0737786faf
commit cfe832383c
17 changed files with 546 additions and 97 deletions
+64
View File
@@ -569,3 +569,67 @@ describe("runGitAction — per-file staging, discard and branches", () => {
});
});
});
describe("runGitAction — one hunk at a time", () => {
beforeEach(freshFixture);
/** A 20-line file edited at both ends: two hunks at the default context. */
function twoHunks() {
const lines = Array.from({ length: 20 }, (_, i) => `line ${i + 1}`);
write(LANE, "big.txt", lines.join("\n") + "\n");
gc(LANE, "add", "big.txt");
gc(LANE, "commit", "-qm", "big");
lines[0] = "FIRST";
lines[19] = "LAST";
write(LANE, "big.txt", lines.join("\n") + "\n");
}
const headers = async () =>
(await collectChanges(LANE, { range: "work", file: "big.txt" })).files[0].hunks.map(
(h) => h.header
);
it("stages ONE hunk, leaving the other in the working tree", async () => {
twoHunks();
const [first] = await headers();
await runGitAction(LANE, "stage-hunk", { path: "big.txt", hunk: first });
const staged = g(LANE, "diff", "--cached", "--", "big.txt");
const unstaged = g(LANE, "diff", "--", "big.txt");
assert.match(staged, /\+FIRST/);
assert.doesNotMatch(staged, /LAST/);
assert.match(unstaged, /\+LAST/);
assert.doesNotMatch(unstaged, /FIRST/);
});
it("undoes ONE hunk in the working tree, only with confirm", async () => {
twoHunks();
const [, second] = await headers();
await assert.rejects(
() => runGitAction(LANE, "discard-hunk", { path: "big.txt", hunk: second }),
{ code: "ECONFIRM" }
);
await runGitAction(LANE, "discard-hunk", { path: "big.txt", hunk: second, confirm: true });
const text = fs.readFileSync(path.join(LANE, "big.txt"), "utf8");
assert.match(text, /^FIRST\n/);
assert.match(text, /line 20\n$/);
});
it("refuses a hunk that is no longer in the diff, and a path that is not changed", async () => {
twoHunks();
await assert.rejects(
() => runGitAction(LANE, "stage-hunk", { path: "big.txt", hunk: "@@ -1,9 +1,9 @@" }),
{ code: "EHUNKGONE" }
);
await assert.rejects(
() => runGitAction(LANE, "stage-hunk", { path: "keep.txt", hunk: "@@ -1 +1 @@" }),
{ code: "EBADPATH" }
);
});
it("sends an untracked file to the per-file actions", async () => {
write(LANE, "new.txt", "x\n");
await assert.rejects(
() => runGitAction(LANE, "stage-hunk", { path: "new.txt", hunk: "@@ -0,0 +1,1 @@" }),
{ code: "EUNTRACKED" }
);
});
});