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
+50 -2
View File
@@ -138,6 +138,28 @@ function hydrate(row) {
return { ...row, stages, links, ports };
}
/**
* A lane's display name, as a column value. The title column is NOT NULL TEXT
* and the dashboard's rename field writes it straight through, so it is
* trimmed and capped — a pasted file is not a lane name. `null` clears the
* title rather than violating the NOT NULL, because clearing is legitimate:
* every render is `lane.title || lane.cwd`. A non-scalar is bad input and is
* rejected the same way every other patch value is (see `bindable`).
*
* @param {unknown} value - Whatever the caller offered as a title.
* @returns {string} A safe column value.
* @throws {Error} code `EBADFIELD` when the value is not a scalar.
*/
const TITLE_MAX = 200;
function normalizeTitle(value) {
if (value === null || value === undefined) return "";
const t = typeof value;
if (t !== "string" && t !== "number" && t !== "bigint") {
throw Object.assign(new Error("title must be a string"), { code: "EBADFIELD" });
}
return String(value).trim().slice(0, TITLE_MAX);
}
function createLane({
title = "",
cwd,
@@ -161,7 +183,7 @@ function createLane({
"INSERT INTO lanes (title, cwd, branch, pipeline, kind, source_repo, base_branch, slug, tool, router_profile, stage_since) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
)
.run(
title,
normalizeTitle(title),
cwd.replace(/\/+$/, ""),
branch,
pipeline,
@@ -184,6 +206,31 @@ function getLane(id) {
return hydrate(db.prepare("SELECT * FROM lanes WHERE id = ?").get(id));
}
/**
* A patch value, as something better-sqlite3 will actually bind.
*
* It binds numbers, strings, bigints, buffers and null — and THROWS on
* anything else, including the `true` and the `{}` a hand-rolled PATCH can
* send for any free-text lane column. That throw is indistinguishable from a
* server fault by the time it reaches Express, so the route answered 500 to
* what is plainly bad input. Rejecting here gives every PATCHABLE field the
* one rule, rather than a special case per field the UI happens to expose.
*
* @param {string} key - The column being patched, for the error message.
* @param {unknown} value - Whatever the caller sent for it.
* @returns {string|number|bigint|null} A bindable value.
* @throws {Error} code `EBADFIELD` when the value is not a scalar.
*/
function bindable(key, value) {
if (key === "title") return normalizeTitle(value);
if (value === null || value === undefined) return null;
const t = typeof value;
if (t === "string" || t === "number" || t === "bigint") return value;
throw Object.assign(new Error(`${key} must be a string, a number or null`), {
code: "EBADFIELD",
});
}
function updateLane(id, patch = {}) {
// Validate kind before building the UPDATE if it's being set
if ("kind" in patch && patch.kind !== null && patch.kind !== undefined) {
@@ -197,7 +244,8 @@ function updateLane(id, patch = {}) {
for (const [k, v] of Object.entries(patch)) {
if (!PATCHABLE.has(k)) continue;
cols.push(`${k} = ?`);
vals.push(k === "links" && typeof v === "object" ? JSON.stringify(v) : v);
if (k === "links" && v !== null && typeof v === "object") vals.push(JSON.stringify(v));
else vals.push(bindable(k, v));
}
if (cols.length) {
cols.push("updated_at = ?");
+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.