feat(lanes): reorder and rename lane cards, and show each file's line delta

The lane strip's tiles are now drag-reorderable (Alt+←/→ is the keyboard
path), and a lane's name can be set in place from the detail header. The
"Lane 23 ·" prefix is gone from both — the bare `#23` stays on the tile,
because that number is how the CLI addresses a lane.

Card order is a per-viewer preference, not a lane fact: it lives in
localStorage (`agenthub.workspace.laneOrder`) beside the split-view layout
and writes nothing server-side, so `GET /api/lanes` and `agenthub lanes`
keep answering in id order. Renaming is the opposite — it is PATCH
/api/lanes/:id with {title}, the one lane column a user now types into
directly.

That made a patch value reachable by hand for the first time, so
`bindable` now rejects any non-scalar for every PATCHABLE column with
EBADFIELD, and the route answers 400 for that and for the
SQLITE_CONSTRAINT family, rather than handing better-sqlite3 a datatype
throw and reporting 500 on plain bad input.

The uncommitted-files tooltip became a real panel. `GET /api/lanes/:id/git`
now returns `files` as {path, status, added, deleted} records — deltas from
one `git diff --numstat HEAD`, null where git has none to report (untracked,
binary, renamed) — and the card renders them portalled to the body, so the
Workspace's own scroll box cannot clip the list.
This commit is contained in:
2026-09-17 15:07:37 +07:00
parent b5ca531800
commit 5bd1c0323f
22 changed files with 1552 additions and 95 deletions
+53 -5
View File
@@ -548,16 +548,51 @@ async function removeWorktree(lane) {
await deleteBranchSafely(sourceRepo, branch, baseBranch);
}
/**
* Per-file line deltas against HEAD, keyed by path.
*
* One `git diff --numstat HEAD` covers staged AND unstaged work in a single
* subprocess, which is the whole reason it is done this way: the alternative
* is one process per file. It says nothing about UNTRACKED files — git has
* never seen them, so they have no delta to report and their counts stay null
* rather than being invented by reading the file off disk (a lane mid-build
* can have an untracked 100 MB artifact).
*
* A repo with no commits yet has no HEAD to diff against; that throw is not a
* fault, it just means every count is unknown.
*/
async function numstatByPath(dir) {
const stats = new Map();
try {
const result = await git(dir, ["diff", "--numstat", "HEAD"]);
for (const line of result.stdout.split("\n")) {
const m = line.match(/^(\d+|-)\t(\d+|-)\t(.+)$/);
// A binary file reports "-" for both, not a number.
if (m) {
stats.set(m[3], {
added: m[1] === "-" ? null : Number(m[1]),
deleted: m[2] === "-" ? null : Number(m[2]),
});
}
}
} catch {
/* no HEAD to diff against -> no per-file deltas */
}
return stats;
}
/**
* Parse git status --porcelain to count dirty, untracked, and get HEAD commit.
* Lines starting with ?? are untracked; others are dirty.
*
* `files` carries the same lines back verbatim so a caller can show WHICH
* files the counts mean, capped because this rides a polled endpoint and a
* lane mid-`npm install` can report thousands.
* `files` names WHICH files the counts mean, each with its porcelain status
* code and its line delta, capped because this rides a polled endpoint and a
* lane mid-`npm install` can report thousands. The COUNTS stay exact — only
* the listing is capped.
*/
async function statusCounts(dir) {
const result = await git(dir, ["status", "--porcelain=v1", "--untracked-files=normal"]);
const stats = await numstatByPath(dir);
let dirty = 0;
let untracked = 0;
const files = [];
@@ -569,7 +604,20 @@ async function statusCounts(dir) {
} else {
dirty++;
}
if (files.length < STATUS_FILE_CAP) files.push(line.trimEnd());
if (files.length < STATUS_FILE_CAP) {
// "XY path", and for a rename "XY old -> new" — the new path is the one
// that exists now and the one numstat is keyed by. git quotes a path
// containing anything unusual; the quotes are presentation, not name.
const raw = line.slice(3).trimEnd();
const path = raw.split(" -> ").pop().replace(/^"|"$/g, "");
files.push({
path,
status: line.slice(0, 2),
added: null,
deleted: null,
...stats.get(path),
});
}
}
// Get short commit hash
@@ -583,7 +631,7 @@ async function statusCounts(dir) {
* What a lane's working copy looks like right now: which branch it is on, the
* short HEAD, that commit's subject, and how much is uncommitted.
*
* Read-only and cheap, but it is three subprocesses, which is why it lives
* Read-only and cheap, but it is five subprocesses, which is why it lives
* behind its own endpoint rather than inside the polled `GET /api/lanes`
* payload. A detached HEAD reports the literal `HEAD` that git returns — the
* caller shows what git says rather than inventing a nicer word for it.