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:
+53
-5
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user