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
+40 -1
View File
@@ -29,6 +29,9 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const {
git: rawGit,
isValidBranchName,
@@ -36,7 +39,7 @@ const {
branchCheckedOutAt,
statusCounts,
} = require("./worktree");
const { laneStatus } = require("./git-diff");
const { hunkPatch, laneStatus } = require("./git-diff");
const git = (dir, args, opts) => rawGit(dir, args, { literal: true, ...opts });
@@ -54,6 +57,8 @@ const ACTIONS = new Set([
"stage",
"unstage",
"discard",
"stage-hunk",
"discard-hunk",
"branch-create",
"branch-delete",
]);
@@ -240,6 +245,40 @@ async function runGitAction(dir, action, body = {}) {
return done(action, mergeResults(out));
}
if (action === "stage-hunk" || action === "discard-hunk") {
// Undoing a hunk is as irreversible as discarding a file: same gate.
if (action === "discard-hunk" && body.confirm !== true) {
throw bad("ECONFIRM", "discard-hunk requires confirm: true");
}
const [target] = await resolvePaths(dir, { path: body.path });
if (target.code === "??") {
throw bad("EUNTRACKED", "an untracked file has no hunks; stage or discard the whole file");
}
const patch = await hunkPatch(
dir,
target.path,
typeof body.hunk === "string" ? body.hunk : "",
body.context
);
if (!patch) throw bad("EHUNKGONE", "that change is no longer in the diff; refresh and retry");
// 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 tmp = await fs.promises.mkdtemp(path.join(os.tmpdir(), "agenthub-hunk-"));
const file = path.join(tmp, "hunk.patch");
try {
await fs.promises.writeFile(file, patch);
const zero = Number(body.context) === 0 ? ["--unidiff-zero"] : [];
// stage: HEAD -> worktree hunk onto the index; undo: the same hunk,
// reversed, off the working tree. A hunk the index or tree no longer
// matches fails in git's own words (409), never half-applies.
const args =
action === "stage-hunk" ? ["apply", "--cached", ...zero] : ["apply", "-R", ...zero];
return done(action, await git(top, [...args, file]));
} finally {
await fs.promises.rm(tmp, { recursive: true, force: true });
}
}
if (action === "commit") {
const message = typeof body.message === "string" ? body.message.trim() : "";
if (!message) throw bad("EBADMESSAGE", "commit message is required");