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
+32
View File
@@ -785,6 +785,37 @@ async function listTree(dir, { subdir = "" } = {}) {
};
}
/**
* ONE hunk of a tracked file's working-copy diff (`git diff HEAD`, the same
* read the Changes panel shows), as a standalone patch `git apply` accepts:
* the file's own header lines plus that hunk. The hunk is named by its `@@`
* line and matched against a FRESH diff at the caller's context, so what gets
* applied is always git's own text of the current tree — never a patch a
* request carried. Null when no hunk matches (the tree moved on, or the file
* is a secret, binary or too large to read whole).
*/
async function hunkPatch(dir, file, header, context) {
if (!header || (await isSecretOnDisk(dir, file))) return null;
const u = `-U${normalizeContext(context)}`;
const raw = await readCapped(
dir,
["diff", u, "HEAD", "--no-color", "--no-ext-diff", "--", file],
MAX_FILE_BYTES
).catch(() => "");
// A capped read may end mid-hunk; applying half a hunk is worse than none.
if (!raw || raw.length >= MAX_FILE_BYTES) return null;
const at = raw.indexOf("\n@@ ");
if (at < 0) return null;
const head = raw.slice(0, at + 1);
// A content line starts with " ", "+", "-" or "\\" — never "@@" — so every
// line that does is a hunk boundary.
const hunk = raw
.slice(at + 1)
.split(/^(?=@@ )/m)
.find((h) => h.slice(0, h.indexOf("\n")) === header);
return hunk ? head + (hunk.endsWith("\n") ? hunk : `${hunk}\n`) : null;
}
/**
* Every level of the tracked tree from ONE `git ls-files`, keyed by directory
* path ("" is the root), each level shaped and capped exactly like
@@ -824,6 +855,7 @@ async function listTreeAll(dir) {
module.exports = {
collectChanges,
hunkPatch,
laneStatus,
listCommits,
listPaths,