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:
@@ -143,6 +143,30 @@ describe("/api/lanes", () => {
|
||||
assert.equal((await request("GET", `/api/lanes/${laneId}`)).status, 404);
|
||||
});
|
||||
|
||||
// The Workspace's inline rename is a text field PATCHing this route, so a
|
||||
// title is the one lane column a user types directly. Bad input on any
|
||||
// patchable column is a 400: reaching better-sqlite3 with a non-scalar
|
||||
// raises a datatype error Express can only report as a 500.
|
||||
it("renames a lane, and answers 400 rather than 500 for unbindable input", async () => {
|
||||
const created = await request("POST", "/api/lanes", { cwd: "/tmp/lane-rename-route" });
|
||||
const id = created.body.lane.id;
|
||||
const named = await request("PATCH", `/api/lanes/${id}`, { title: " checkout flow " });
|
||||
assert.equal(named.status, 200);
|
||||
assert.equal(named.body.lane.title, "checkout flow");
|
||||
const cleared = await request("PATCH", `/api/lanes/${id}`, { title: "" });
|
||||
assert.equal(cleared.body.lane.title, "");
|
||||
|
||||
for (const patch of [{ title: { evil: true } }, { notes: { a: 1 } }, { ci_status: true }]) {
|
||||
const bad = await request("PATCH", `/api/lanes/${id}`, patch);
|
||||
assert.equal(bad.status, 400, `expected 400 for ${JSON.stringify(patch)}`);
|
||||
assert.equal(bad.body.error.code, "EBADFIELD");
|
||||
}
|
||||
// A NOT NULL column patched to null is the caller's mistake too.
|
||||
const notNull = await request("PATCH", `/api/lanes/${id}`, { status: null });
|
||||
assert.equal(notNull.status, 400);
|
||||
assert.equal((await request("DELETE", `/api/lanes/${id}`)).status, 200);
|
||||
});
|
||||
|
||||
it("rejects cross-origin lane deletion", async () => {
|
||||
const created = await request("POST", "/api/lanes", { cwd: "/tmp/lane-cross-origin-delete" });
|
||||
const id = created.body.lane.id;
|
||||
|
||||
@@ -358,6 +358,48 @@ describe("lanes lib", () => {
|
||||
assert.equal(after.tool, "codex");
|
||||
assert.equal(after.title, "x");
|
||||
});
|
||||
|
||||
// The Workspace's inline rename PATCHes `title` straight from a text field,
|
||||
// so the column is now reachable by anything same-origin. A non-string would
|
||||
// reach better-sqlite3 as a datatype throw and surface as a 500 on what is
|
||||
// plain bad input.
|
||||
it("normalizes a title on create and on update", () => {
|
||||
const lane = lanes.createLane({ cwd: "/tmp/title-norm", title: " spaced " });
|
||||
assert.equal(lane.title, "spaced");
|
||||
assert.equal(lanes.updateLane(lane.id, { title: 42 }).title, "42");
|
||||
assert.equal(lanes.updateLane(lane.id, { title: "x".repeat(500) }).title.length, 200);
|
||||
// null clears rather than violating the NOT NULL, and the empty string is
|
||||
// a real value: it puts the lane's cwd back in the UI's place for it.
|
||||
assert.equal(lanes.updateLane(lane.id, { title: null }).title, "");
|
||||
assert.equal(lanes.updateLane(lane.id, { title: "" }).title, "");
|
||||
});
|
||||
|
||||
// Every free-text column, not just the one the rename field exposes: a
|
||||
// non-scalar reaches better-sqlite3 as a datatype throw, which Express can
|
||||
// only report as a 500 on what is plainly the caller's mistake.
|
||||
it("rejects a non-scalar patch value on any patchable column", () => {
|
||||
const lane = lanes.createLane({ cwd: "/tmp/patch-scalar" });
|
||||
for (const patch of [
|
||||
{ title: { evil: true } },
|
||||
{ notes: { a: 1 } },
|
||||
{ branch: ["main"] },
|
||||
{ ci_status: true },
|
||||
{ session_id: () => "x" },
|
||||
]) {
|
||||
assert.throws(
|
||||
() => lanes.updateLane(lane.id, patch),
|
||||
(err) => err.code === "EBADFIELD",
|
||||
`expected EBADFIELD for ${JSON.stringify(Object.keys(patch))}`
|
||||
);
|
||||
}
|
||||
assert.throws(
|
||||
() => lanes.createLane({ cwd: "/tmp/patch-scalar-create", title: { evil: true } }),
|
||||
(err) => err.code === "EBADFIELD"
|
||||
);
|
||||
// `links` is the one patchable column that IS an object — it is stored as
|
||||
// JSON, so it must keep working.
|
||||
assert.deepEqual(lanes.updateLane(lane.id, { links: { pr: "u" } }).links, { pr: "u" });
|
||||
});
|
||||
});
|
||||
|
||||
const { withLaneLock } = require("../lib/lane-lock");
|
||||
|
||||
@@ -100,8 +100,16 @@ describe("worktree", () => {
|
||||
assert.equal(s.dirty, 1);
|
||||
assert.equal(s.untracked, 1); // node_modules is ignored, so it does not count
|
||||
assert.match(s.head, /^[0-9a-f]{7,40}$/);
|
||||
// The same lines the counts came from, so a caller can name the files.
|
||||
assert.deepEqual(s.files.sort(), [" M README.md", "?? scratch.txt"]);
|
||||
// The files the counts came from, each with its porcelain status code and
|
||||
// its line delta. An UNTRACKED file has no delta to report — git has never
|
||||
// seen it — so its counts are null rather than invented.
|
||||
assert.deepEqual(
|
||||
s.files.sort((a, b) => a.path.localeCompare(b.path)),
|
||||
[
|
||||
{ path: "README.md", status: " M", added: 1, deleted: 0 },
|
||||
{ path: "scratch.txt", status: "??", added: null, deleted: null },
|
||||
]
|
||||
);
|
||||
// The fixture has no remotes. Given the lane's base branch, unpushedCount
|
||||
// measures base..HEAD — this worktree has committed nothing of its own, so 0.
|
||||
assert.equal(await wt.unpushedCount(dir, "main"), 0);
|
||||
|
||||
Reference in New Issue
Block a user