feat(lanes): split panes on one lane, in-place file editing, hunk review bar
- Split view: every pane shows the selected lane, each in its own tmux session (`slot` 0-3 on POST /api/lanes/:id/start; slot 0 stays the lane's `run_id`, slots 1-3 are `agenthub-lane-<id>-<slot>`). A pane's Active runs hides what the other panes show; stop/reset/remove kill every slot. No per-pane lane picker any more. - Whole-file editor: PUT /api/lanes/:id/git/file (lane-file-write.js) with the read path's guards, existing text files only, and a baseSha stale check (409 ESTALE). A file it cannot take says why. - Per-hunk review bar under each hunk's last changed line: prev/next, "n of N", Undo, Keep. Review diffs no longer nest their own scroll box (unless windowed), so navigation moves the one real scroller. - Files tree lists untracked-not-ignored files and colours entries by git status (M/U/A/D/R), folders holding changes get a dot. - Changes panel: Unstage all on the single commit/Stage-all row; sizes Narrow (30%) / Medium / Full screen, no 70/30. - Lane card: the needs-you banner is gone (the strip tile keeps it).
This commit is contained in:
@@ -959,6 +959,47 @@ describe("the lane git surface: changes, branches, actions (issue #34)", () => {
|
||||
assert.ok(tree.body.entries.some((e) => e.name === "a.txt" && e.dir === false));
|
||||
});
|
||||
|
||||
it("saves a file from the editor over the exact bytes it loaded, and nothing else", async () => {
|
||||
const opened = await request("GET", `/api/lanes/${laneId}/git/file?path=a.txt&rev=work`);
|
||||
assert.equal(typeof opened.body.sha, "string");
|
||||
|
||||
const saved = await request("PUT", `/api/lanes/${laneId}/git/file`, {
|
||||
path: "a.txt",
|
||||
content: "one\nTWO\nthree",
|
||||
baseSha: opened.body.sha,
|
||||
});
|
||||
assert.equal(saved.status, 200, JSON.stringify(saved.body));
|
||||
// The final newline the file had survives an editor that drops it.
|
||||
assert.equal(fs.readFileSync(path.join(dir, "a.txt"), "utf8"), "one\nTWO\nthree\n");
|
||||
assert.notEqual(saved.body.sha, opened.body.sha);
|
||||
|
||||
// Saving over the OLD stamp is a conflict, never a silent overwrite.
|
||||
const stale = await request("PUT", `/api/lanes/${laneId}/git/file`, {
|
||||
path: "a.txt",
|
||||
content: "clobbered",
|
||||
baseSha: opened.body.sha,
|
||||
});
|
||||
assert.equal(stale.status, 409);
|
||||
assert.equal(stale.body.error.code, "ESTALE");
|
||||
assert.equal(fs.readFileSync(path.join(dir, "a.txt"), "utf8"), "one\nTWO\nthree\n");
|
||||
|
||||
const escape = await request("PUT", `/api/lanes/${laneId}/git/file`, {
|
||||
path: "../../etc/passwd",
|
||||
content: "x",
|
||||
baseSha: "0".repeat(40),
|
||||
});
|
||||
assert.equal(escape.status, 400);
|
||||
assert.equal(escape.body.error.code, "EBADPATH");
|
||||
|
||||
const secret = await request("PUT", `/api/lanes/${laneId}/git/file`, {
|
||||
path: ".claude/settings.local.json",
|
||||
content: "{}",
|
||||
baseSha: "0".repeat(40),
|
||||
});
|
||||
assert.equal(secret.status, 403);
|
||||
assert.equal(secret.body.error.code, "ESECRET");
|
||||
});
|
||||
|
||||
it("stages one file, commits only the index, and refuses an unchanged path", async () => {
|
||||
fs.writeFileSync(path.join(dir, "a.txt"), "one\nTHREE\n");
|
||||
fs.writeFileSync(path.join(dir, "b.txt"), "left behind\n");
|
||||
|
||||
Reference in New Issue
Block a user