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,