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 -2
View File
@@ -585,6 +585,20 @@ const paths = {
},
},
},
"/api/lanes/{id}/git/tree-all": {
get: {
tags: ["Lanes"],
summary: "Every level of the tracked tree in one read",
description:
"The file explorer's expand-all-folders read: ONE git ls-files, grouped into levels keyed by directory (\"\" is the root), each level shaped and capped like GET /api/lanes/{id}/git/tree's entries. Read-only, no same-origin guard.",
operationId: "listLaneTreeAll",
parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
responses: {
200: { description: "{available, levels:{<dir>:[{name, path, dir}]}, truncated}." },
404: { description: "Lane not found." },
},
},
},
"/api/lanes/{id}/git/branches": {
get: {
tags: ["Lanes"],
@@ -604,7 +618,7 @@ const paths = {
tags: ["Lanes"],
summary: "One git write on the lane's working copy",
description:
"fetch (--all --prune), pull (ALWAYS --ff-only — a diverged branch is reported, never merged), push (-u <remote> HEAD, so only the branch the lane is on; remote = the branch's upstream remote, else the only remote, else origin, else 400 ENOREMOTE), stage/unstage (per file), discard (per file; irreversible, so it REQUIRES confirm:true), commit (git add -A then commit -m <message>, or only:\"staged\" to take the index as it stands), switch (git switch, -c to create; refuses a dirty tree so an agent's half-finished work is never carried onto another branch), and branch-create / branch-delete (create never switches; delete is `git branch -d`, refuses main/master/development/develop, the current branch and one held by another worktree). A path in the body must be one `git status` is reporting as changed, so no path outside the lane is reachable. Guarded and serialized per git index (the lane lock keyed on the worktree's git dir, so two lanes sharing one index cannot collide on index.lock). Deliberately no merge, rebase, reset or stash: reset stays behind the destroy guard on POST /api/lanes/{id}/reset. Network actions are killed after 60s.",
"fetch (--all --prune), pull (ALWAYS --ff-only — a diverged branch is reported, never merged), push (-u <remote> HEAD, so only the branch the lane is on; remote = the branch's upstream remote, else the only remote, else origin, else 400 ENOREMOTE), stage/unstage (per file), discard (per file; irreversible, so it REQUIRES confirm:true), stage-hunk / discard-hunk (ONE hunk of a tracked file's `git diff HEAD`, named by its `@@` line and re-read server-side at the given context — the applied patch is always git's own text, never one from the request; discard-hunk REQUIRES confirm:true; 400 EUNTRACKED for an untracked file, 409 EHUNKGONE when the hunk is no longer in the diff), commit (git add -A then commit -m <message>, or only:\"staged\" to take the index as it stands), switch (git switch, -c to create; refuses a dirty tree so an agent's half-finished work is never carried onto another branch), and branch-create / branch-delete (create never switches; delete is `git branch -d`, refuses main/master/development/develop, the current branch and one held by another worktree). A path in the body must be one `git status` is reporting as changed, so no path outside the lane is reachable. Guarded and serialized per git index (the lane lock keyed on the worktree's git dir, so two lanes sharing one index cannot collide on index.lock). Deliberately no merge, rebase, reset or stash: reset stays behind the destroy guard on POST /api/lanes/{id}/reset. Network actions are killed after 60s.",
operationId: "runLaneGitAction",
parameters: [
{ name: "id", in: "path", required: true, schema: { type: "integer" } },
@@ -621,6 +635,8 @@ const paths = {
"stage",
"unstage",
"discard",
"stage-hunk",
"discard-hunk",
"commit",
"switch",
"branch-create",
@@ -653,9 +669,23 @@ const paths = {
items: { type: "string" },
description: "stage, unstage, discard; each must be a path git reports changed.",
},
path: {
type: "string",
description:
"stage-hunk, discard-hunk; a path git reports changed (not untracked).",
},
hunk: {
type: "string",
description:
"stage-hunk, discard-hunk; the hunk's `@@` line as the diff returned it. The server re-reads the diff and applies git's own text of that hunk; a hunk no longer present is 409 EHUNKGONE.",
},
context: {
type: "integer",
description: "stage-hunk, discard-hunk; the context the hunk was read at.",
},
confirm: {
type: "boolean",
description: "discard only; the call is refused without it.",
description: "discard and discard-hunk; the call is refused without it.",
},
},
},