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:
2026-09-24 15:49:39 +07:00
parent 67210bcd61
commit d397d3a1f6
29 changed files with 1289 additions and 254 deletions
+74 -16
View File
@@ -24,6 +24,7 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const crypto = require("node:crypto");
const fs = require("node:fs");
const path = require("node:path");
@@ -667,6 +668,7 @@ async function readFileAt(dir, requested, rev = "work") {
let text;
let binary = false;
let sha;
if (rev === "work") {
// Neither `..` nor a symlink may walk out of the lane: resolveInLane
// compares REAL paths, not the string the caller sent.
@@ -695,6 +697,9 @@ async function readFileAt(dir, requested, rev = "work") {
}
binary = looksBinary(buf);
text = binary ? "" : buf.toString("utf8");
// What an editor must send back to save over THIS content and nothing
// newer (lane-file-write.js). Only for a whole, textual file.
if (!binary && buf.length <= MAX_FILE_VIEW_BYTES) sha = contentSha(buf);
} else {
try {
// `./`: resolve from the lane's cwd, not the repo root (a bare
@@ -715,7 +720,12 @@ async function readFileAt(dir, requested, rev = "work") {
}
const lines = text ? text.split("\n") : [];
if (lines[lines.length - 1] === "") lines.pop();
return { path: file, rev, binary, truncated, lines };
return { path: file, rev, binary, truncated, lines, ...(sha ? { sha } : {}) };
}
/** The version stamp of a file's bytes, for a stale-save check. */
function contentSha(buf) {
return crypto.createHash("sha1").update(buf).digest("hex");
}
/**
@@ -753,18 +763,67 @@ async function lsFiles(dir, args) {
return { all: all.filter(Boolean), cut };
}
/** What the explorer lists: tracked files plus untracked ones git does not
* ignore — the new file an agent just wrote is the one you want to open. */
const TREE_LS = ["--cached", "--others", "--exclude-standard"];
/**
* One directory level of the working tree, for the file explorer. Tracked
* entries only (same `.gitignore` reasoning as `listPaths`), folded into
* A lane's working-copy status for decorating the explorer, from ONE
* `laneStatus`: `fileStatus(path)` is the porcelain code of a changed file
* (untracked inside a folded untracked dir included), `dirChanged(path)` is
* whether anything under a folder is.
*/
async function treeStatus(dir) {
const codes = new Map();
const folded = []; // `normal` reports a wholly untracked folder as "dir/"
const dirs = new Set();
for (const { code, path: p } of await laneStatus(dir).catch(() => [])) {
if (p.endsWith("/")) folded.push(p);
else codes.set(p, code);
const parts = p.replace(/\/$/, "").split("/");
for (let i = 1; i <= parts.length - (p.endsWith("/") ? 0 : 1); i++) {
dirs.add(parts.slice(0, i).join("/"));
}
}
const inFolded = (p) => folded.some((f) => p.startsWith(f));
return {
fileStatus: (p) => codes.get(p) ?? (inFolded(p) ? "??" : undefined),
dirChanged: (p) => dirs.has(p) || inFolded(`${p}/`),
};
}
/** A folded level's entries, each carrying its status (see `treeStatus`). */
function treeEntries(prefix, dirNames, files, st) {
return [
...[...dirNames].sort().map((name) => {
const p = `${prefix}${name}`;
return { name, path: p, dir: true, ...(st.dirChanged(p) ? { changed: true } : {}) };
}),
...files
.sort((a, b) => a.name.localeCompare(b.name))
.map((f) => {
const status = st.fileStatus(f.path);
return status ? { ...f, status } : f;
}),
];
}
/**
* One directory level of the working tree, for the file explorer: tracked
* files plus untracked-but-not-ignored ones (`TREE_LS`), folded into
* directories at this level — a tree that lists a repo's every file at once is
* both slow to render and not what "expand a folder" means.
* both slow to render and not what "expand a folder" means. A changed file
* carries its porcelain `status`, a folder holding one `changed: true`.
*/
async function listTree(dir, { subdir = "" } = {}) {
const clean = String(subdir || "")
.replace(/^\/+|\/+$/g, "")
.trim();
if (clean.split("/").includes("..")) throw badRequest("EBADPATH", "path escapes the lane");
const { all, cut } = await lsFiles(dir, clean ? ["--", `${clean}/`] : []);
const [{ all, cut }, st] = await Promise.all([
lsFiles(dir, clean ? [...TREE_LS, "--", `${clean}/`] : TREE_LS),
treeStatus(dir),
]);
const prefix = clean ? `${clean}/` : "";
const dirs = new Set();
const files = [];
@@ -774,10 +833,7 @@ async function listTree(dir, { subdir = "" } = {}) {
if (slash === -1) files.push({ name: rest, path: p, dir: false });
else dirs.add(rest.slice(0, slash));
}
const entries = [
...[...dirs].sort().map((name) => ({ name, path: `${prefix}${name}`, dir: true })),
...files.sort((a, b) => a.name.localeCompare(b.name)),
];
const entries = treeEntries(prefix, dirs, files, st);
return {
path: clean,
entries: entries.slice(0, MAX_PATHS),
@@ -817,13 +873,13 @@ async function hunkPatch(dir, file, header, context) {
}
/**
* Every level of the tracked tree from ONE `git ls-files`, keyed by directory
* Every level of the explorer tree (tracked + untracked-not-ignored) from ONE `git ls-files`, keyed by directory
* path ("" is the root), each level shaped and capped exactly like
* `listTree`'s. "Expand all folders" used to be one request — one ls-files —
* per folder, which on a large repo is thousands of git processes.
*/
async function listTreeAll(dir) {
const { all, cut } = await lsFiles(dir, []);
const [{ all, cut }, st] = await Promise.all([lsFiles(dir, TREE_LS), treeStatus(dir)]);
const byDir = new Map();
const level = (d) => {
let l = byDir.get(d);
@@ -842,11 +898,7 @@ async function listTreeAll(dir) {
const levels = {};
let truncated = cut;
for (const [d, { dirs, files }] of byDir) {
const prefix = d ? `${d}/` : "";
const entries = [
...[...dirs].sort().map((name) => ({ name, path: `${prefix}${name}`, dir: true })),
...files.sort((a, b) => a.name.localeCompare(b.name)),
];
const entries = treeEntries(d ? `${d}/` : "", dirs, files, st);
if (entries.length > MAX_PATHS) truncated = true;
levels[d] = entries.slice(0, MAX_PATHS);
}
@@ -855,6 +907,12 @@ async function listTreeAll(dir) {
module.exports = {
collectChanges,
contentSha,
isSecretOnDisk,
lanePath,
looksBinary,
resolveInLane,
MAX_FILE_VIEW_BYTES,
hunkPatch,
laneStatus,
listCommits,
+77
View File
@@ -0,0 +1,77 @@
/**
* @file Saves one text file in a lane's working tree, for the Changes panel's
* single-file editor. The one filesystem WRITE of the lane file surface, kept
* out of git-ops.js on purpose: that module only ever touches paths `git
* status` reports as changed, and an editor may save any file.
*
* The guards are the READ path's own (git-diff.js), so the editor can never
* write where the viewer could not read: a lexical `lanePath`, a realpath
* confined to the lane (no `..`, no symlink out), and no `settings.local.json`
* — the lane router's key file — at any depth or through any symlink. On top:
* only an EXISTING, textual file within the viewer's size cap, and only over
* the exact bytes the editor loaded (`baseSha`, from `readFileAt`), so a save
* never silently overwrites what the agent wrote in the meantime (ESTALE).
* The file keeps its CRLF line endings and its final newline.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const {
contentSha,
isSecretOnDisk,
lanePath,
looksBinary,
resolveInLane,
MAX_FILE_VIEW_BYTES,
} = require("./git-diff");
function bad(code, message) {
return Object.assign(new Error(message), { code });
}
/**
* Overwrite `requested` in lane `dir` with `content`. Throws EBADPATH,
* ESECRET, ENOFILE, EBADCONTENT, ETOOBIG, EBINARY or ESTALE.
*/
async function writeLaneFile(dir, requested, content, baseSha) {
const file = lanePath(requested);
if (await isSecretOnDisk(dir, file)) {
throw bad("ESECRET", "that file holds secrets and is not writable here");
}
if (typeof content !== "string") throw bad("EBADCONTENT", "content must be a string");
if (typeof baseSha !== "string" || !/^[0-9a-f]{40}$/.test(baseSha)) {
throw bad("EBADCONTENT", "baseSha is required");
}
if (Buffer.byteLength(content) > MAX_FILE_VIEW_BYTES) {
throw bad("ETOOBIG", "file is too large to edit here");
}
const abs = await resolveInLane(dir, file);
let current;
try {
const stat = await fs.promises.stat(abs);
if (!stat.isFile()) throw bad("ENOFILE", "no such file in the working tree");
if (stat.size > MAX_FILE_VIEW_BYTES) throw bad("ETOOBIG", "file is too large to edit here");
current = await fs.promises.readFile(abs);
} catch (err) {
if (err.code === "ETOOBIG" || err.code === "ENOFILE") throw err;
throw bad("ENOFILE", "no such file in the working tree");
}
if (looksBinary(current)) throw bad("EBINARY", "binary files are not editable here");
if (contentSha(current) !== baseSha) {
throw bad("ESTALE", "the file changed on disk since it was opened — reload it first");
}
// A browser textarea hands back LF only, and the viewer drops the final
// newline: put back what the file had, so a one-word fix is a one-line diff.
const text = current.toString("utf8");
const eol = text.includes("\r\n") ? "\r\n" : "\n";
let out = content.replace(/\r\n/g, "\n");
if (eol === "\r\n") out = out.replace(/\n/g, "\r\n");
if (text.endsWith("\n") && out && !out.endsWith(eol)) out += eol;
await fs.promises.writeFile(abs, out);
return { path: file };
}
module.exports = { writeLaneFile };
+2 -2
View File
@@ -12,10 +12,10 @@
const tmux = require("./tmux");
const RUN_ID_RE = /^agenthub-lane-\d+$/;
const RUN_ID_RE = /^agenthub-lane-\d+(?:-[1-3])?$/;
/**
* Reject anything that isn't exactly `agenthub-lane-<digits>` before it can ever
* Reject anything that isn't exactly `agenthub-lane-<digits>[-<1-3>]` before it can ever
* reach a tmux/PTY command — the trust boundary for this WS path, since a
* validated runId is the only thing standing between an authenticated WS
* client and naming an arbitrary session on the host.
+21 -4
View File
@@ -21,6 +21,10 @@
*
* Spawns `claude` or `codex` in the tmux pane based on the tool parameter.
*
* A lane can hold up to four sessions, one per Workspace split pane: pane 0 is
* `agenthub-lane-<laneId>` (the lane's own `run_id`), panes 1–3 are
* `agenthub-lane-<laneId>-<slot>`.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -35,7 +39,9 @@ try {
/* db-less environment, skip persistence */
}
const RUN_ID_RE = /^agenthub-lane-(\d+)$/;
const RUN_ID_RE = /^agenthub-lane-(\d+)(?:-([1-3]))?$/;
/** Highest pane slot a lane may use (the Workspace's 4-pane grid is 0–3). */
const MAX_PANE_SLOT = 3;
const EFFORT_LEVELS = new Set(["low", "medium", "high", "xhigh", "max"]);
// `auto` is the CLI's own default-ish mode (`claude --permission-mode auto`):
// it asks only for what its classifier reads as risky. `default` is kept for
@@ -55,8 +61,13 @@ const CODEX_APPROVALS = new Set(["on-request", "never"]);
// Codex has no `--effort` flag; reasoning effort is a config override.
const CODEX_EFFORTS = new Set(["low", "medium", "high", "xhigh"]);
function runIdForLane(laneId) {
return `agenthub-lane-${laneId}`;
function runIdForLane(laneId, slot = 0) {
return slot ? `agenthub-lane-${laneId}-${slot}` : `agenthub-lane-${laneId}`;
}
function slotFromRunId(id) {
const m = typeof id === "string" ? id.match(RUN_ID_RE) : null;
return m ? Number(m[2] || 0) : null;
}
function laneIdFromRunId(id) {
@@ -149,6 +160,7 @@ function buildArgv(args) {
function spawnRun(args) {
const {
laneId,
slot = 0,
cwd,
tool = "claude",
model,
@@ -164,6 +176,9 @@ function spawnRun(args) {
if (typeof laneId !== "number" || !Number.isInteger(laneId)) {
throw makeErr("EBADLANE", "laneId must be an integer");
}
if (!Number.isInteger(slot) || slot < 0 || slot > MAX_PANE_SLOT) {
throw makeErr("EBADSLOT", `slot must be an integer 0–${MAX_PANE_SLOT}`);
}
if (typeof cwd !== "string" || !cwd) {
throw makeErr("EBADCWD", "cwd is required");
}
@@ -215,7 +230,7 @@ function spawnRun(args) {
);
}
const id = runIdForLane(laneId);
const id = runIdForLane(laneId, slot);
const startedAt = Date.now();
const argv = buildArgv({
tool,
@@ -323,6 +338,7 @@ function publicRun(id) {
return {
id,
laneId,
slot: slotFromRunId(id),
status: live ? "running" : "gone",
tool: row?.tool || "claude",
cwd: row?.cwd || null,
@@ -353,5 +369,6 @@ module.exports = {
listRuns,
laneIdFromRunId,
runIdForLane,
MAX_PANE_SLOT,
SHELL_COMMANDS,
};