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:
+40
-1
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user