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:
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* @file laneOrderStorage.test.ts
|
||||
* @description Tests for the Workspace lane strip's stored card order: the
|
||||
* storage round-trip, and the two rules that keep a stale stored order from
|
||||
* losing a lane — unknown ids land at the end, vanished ids are skipped.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { readLaneOrder, writeLaneOrder, sortByStoredOrder, moveLane } from "../laneOrderStorage";
|
||||
|
||||
const lanes = (...ids: number[]) => ids.map((id) => ({ id }));
|
||||
|
||||
describe("laneOrderStorage", () => {
|
||||
beforeEach(() => localStorage.clear());
|
||||
|
||||
it("returns an empty order when nothing is stored", () => {
|
||||
expect(readLaneOrder()).toEqual([]);
|
||||
});
|
||||
|
||||
it("round-trips a written order", () => {
|
||||
writeLaneOrder([3, 1, 2]);
|
||||
expect(readLaneOrder()).toEqual([3, 1, 2]);
|
||||
});
|
||||
|
||||
it("falls back to an empty order when stored JSON is malformed", () => {
|
||||
localStorage.setItem("agenthub.workspace.laneOrder", "{not json");
|
||||
expect(readLaneOrder()).toEqual([]);
|
||||
});
|
||||
|
||||
it("drops non-numeric entries from a stored order", () => {
|
||||
localStorage.setItem("agenthub.workspace.laneOrder", JSON.stringify([1, "2", null, 3]));
|
||||
expect(readLaneOrder()).toEqual([1, 3]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sortByStoredOrder", () => {
|
||||
it("keeps server order when nothing is stored", () => {
|
||||
expect(sortByStoredOrder(lanes(1, 2, 3), [])).toEqual(lanes(1, 2, 3));
|
||||
});
|
||||
|
||||
it("applies the stored order", () => {
|
||||
expect(sortByStoredOrder(lanes(1, 2, 3), [3, 1, 2])).toEqual(lanes(3, 1, 2));
|
||||
});
|
||||
|
||||
it("appends lanes the stored order has never seen, in server order", () => {
|
||||
expect(sortByStoredOrder(lanes(1, 2, 3, 4), [3, 1])).toEqual(lanes(3, 1, 2, 4));
|
||||
});
|
||||
|
||||
it("skips stored ids whose lane no longer exists", () => {
|
||||
expect(sortByStoredOrder(lanes(1, 3), [3, 2, 1])).toEqual(lanes(3, 1));
|
||||
});
|
||||
|
||||
it("does not mutate the input", () => {
|
||||
const input = lanes(1, 2, 3);
|
||||
sortByStoredOrder(input, [3, 2, 1]);
|
||||
expect(input.map((l) => l.id)).toEqual([1, 2, 3]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("moveLane", () => {
|
||||
it("moves a card forward", () => {
|
||||
expect(moveLane([1, 2, 3], 0, 2)).toEqual([2, 3, 1]);
|
||||
});
|
||||
|
||||
it("moves a card backward", () => {
|
||||
expect(moveLane([1, 2, 3], 2, 0)).toEqual([3, 1, 2]);
|
||||
});
|
||||
|
||||
it("returns the order unchanged for a no-op or out-of-range move", () => {
|
||||
expect(moveLane([1, 2, 3], 1, 1)).toEqual([1, 2, 3]);
|
||||
expect(moveLane([1, 2, 3], 0, 3)).toEqual([1, 2, 3]);
|
||||
expect(moveLane([1, 2, 3], -1, 0)).toEqual([1, 2, 3]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* @file laneOrderStorage.ts
|
||||
* @description Persists the Workspace lane strip's user-chosen card order to
|
||||
* localStorage, the same read/validate/fallback convention as
|
||||
* splitViewStorage.ts.
|
||||
*
|
||||
* The order lives in the browser rather than the lanes table on purpose: it is
|
||||
* a per-viewer preference, like the split-pane layout beside it, and nothing
|
||||
* server-side (the CLI's `agenthub lanes`, the pipeline-stats picker) has an
|
||||
* opinion about it. Ceiling: the order does not follow the user to another
|
||||
* browser or to the CLI — move it to a `lanes.sort_order` column if it ever
|
||||
* must.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = "agenthub.workspace.laneOrder";
|
||||
|
||||
export function readLaneOrder(): number[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return [];
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed.filter((id): id is number => typeof id === "number");
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function writeLaneOrder(ids: number[]): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(ids));
|
||||
} catch {
|
||||
/* ignore quota / disabled storage */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a stored id order to a freshly fetched lane list.
|
||||
*
|
||||
* Ids the stored order never saw — a lane created since, or created in another
|
||||
* tab — keep their server order (id ascending) and land at the END, so a new
|
||||
* lane appears predictably rather than wherever a stale index puts it. Stored
|
||||
* ids naming a lane that no longer exists are simply skipped.
|
||||
*
|
||||
* @param lanes Lanes in server order.
|
||||
* @param order Stored id order.
|
||||
* @returns A new array; the input is never mutated.
|
||||
*/
|
||||
export function sortByStoredOrder<T extends { id: number }>(lanes: T[], order: number[]): T[] {
|
||||
const rank = new Map(order.map((id, i) => [id, i]));
|
||||
const known = lanes
|
||||
.filter((l) => rank.has(l.id))
|
||||
.sort((a, b) => rank.get(a.id)! - rank.get(b.id)!);
|
||||
const fresh = lanes.filter((l) => !rank.has(l.id));
|
||||
return [...known, ...fresh];
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the lane at `from` to index `to`, returning the resulting id order.
|
||||
* Out-of-range indices yield the unchanged order rather than a hole.
|
||||
*/
|
||||
export function moveLane(ids: number[], from: number, to: number): number[] {
|
||||
if (from === to || from < 0 || to < 0 || from >= ids.length || to >= ids.length) return ids;
|
||||
const next = [...ids];
|
||||
next.splice(to, 0, ...next.splice(from, 1));
|
||||
return next;
|
||||
}
|
||||
+14
-2
@@ -2592,11 +2592,23 @@ export type LaneGitFacts =
|
||||
dirty: number;
|
||||
/** Files git has never seen. */
|
||||
untracked: number;
|
||||
/** Raw `git status --porcelain` lines, capped; the counts stay exact. */
|
||||
files: string[];
|
||||
/** The files behind the counts, capped; the counts themselves stay exact. */
|
||||
files: LaneGitFile[];
|
||||
}
|
||||
| { available: false };
|
||||
|
||||
/** One uncommitted file, as `GET /api/lanes/:id/git` reports it. */
|
||||
export interface LaneGitFile {
|
||||
/** Path relative to the repo root; for a rename, the new path. */
|
||||
path: string;
|
||||
/** The two-character `git status --porcelain` code, e.g. ` M`, `??`, `A `. */
|
||||
status: string;
|
||||
/** Lines added, or null when git has no delta for it (untracked, binary). */
|
||||
added: number | null;
|
||||
/** Lines removed, or null for the same reasons. */
|
||||
deleted: number | null;
|
||||
}
|
||||
|
||||
/** Aggregate counts across all lanes. */
|
||||
export interface LaneCounts {
|
||||
total: number;
|
||||
|
||||
Reference in New Issue
Block a user