feat: workspace aliases, tab groups of split panes, native path picker (v2.1.0)

- /api/workspaces and `agenthub workspace(s)` / `--workspace` as aliases of
  /api/lanes and `agenthub lanes` / `--lane`; old names print one stderr note.
  REPL `help lanes|workspace|workspaces` resolves through the aliases.
- A workspace holds up to ten sessions (slots 0-9); every console tab is a
  group of 1-4 split panes (PaneGroups, agenthub.workspace.paneLayout);
  parseGroups keeps the stored active tab when an earlier one is dropped.
- Browse button beside path fields: POST /api/lanes/pick-path opens the OS
  picker on the server's desktop — loopback only and no forwarding header
  (EREMOTE), a display failure is ENODIALOG not a Cancel, the dialog is
  killed when the request closes, a folder field falls back to the in-app
  browser on any failure.
- Changes rail counts chip, image viewer (/git/raw), status bar session
  counts and per-day cost (`date=` on /api/pricing/cost).
- /agenthub-open and /agenthub-doctor run from the INSTALLED build
  (supersededBy), doctor offers fixes behind two confirmations; plugin-open
  treats a locked bootstrap as non-fatal.
- New-terminal shell waits for the first attach (issue #55).
- repo-onboarding / debug-live-issue skills removed.
- OpenAPI: pick-path, lane/date on /pricing/cost, tool on /run/history,
  /api/workspaces mount.
This commit is contained in:
2026-10-06 11:31:26 +07:00
parent c067abc68f
commit 6c605849d8
104 changed files with 3478 additions and 1396 deletions
@@ -2,12 +2,20 @@
* @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.
* losing a lane — unknown ids land at the end, vanished ids are skipped — plus
* the drop math: a drop lands between items by the pointer's position.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, beforeEach } from "vitest";
import { readLaneOrder, writeLaneOrder, sortByStoredOrder, moveLane } from "../laneOrderStorage";
import {
readLaneOrder,
writeLaneOrder,
sortByStoredOrder,
moveLane,
insertionIndex,
moveBefore,
} from "../laneOrderStorage";
const lanes = (...ids: number[]) => ids.map((id) => ({ id }));
@@ -72,4 +80,21 @@ describe("moveLane", () => {
expect(moveLane([1, 2, 3], 0, 3)).toEqual([1, 2, 3]);
expect(moveLane([1, 2, 3], -1, 0)).toEqual([1, 2, 3]);
});
it("places a drop before the first item whose middle is past the pointer", () => {
const mids = [10, 30, 50];
expect(insertionIndex(mids, 0)).toBe(0);
expect(insertionIndex(mids, 20)).toBe(1); // a gap between two items
expect(insertionIndex(mids, 49)).toBe(2);
expect(insertionIndex(mids, 90)).toBe(3); // past the last: the end
});
it("moves an item to just before an insertion point", () => {
expect(moveBefore([1, 2, 3, 4], 0, 2)).toEqual([2, 1, 3, 4]);
expect(moveBefore([1, 2, 3, 4], 0, 4)).toEqual([2, 3, 4, 1]);
expect(moveBefore([1, 2, 3, 4], 3, 0)).toEqual([4, 1, 2, 3]);
// Before itself or right after itself: no move.
expect(moveBefore([1, 2, 3], 1, 1)).toEqual([1, 2, 3]);
expect(moveBefore([1, 2, 3], 1, 2)).toEqual([1, 2, 3]);
});
});
+124 -11
View File
@@ -3,26 +3,36 @@
* @description Tests for the session view's split-pane tree: opening a run
* (replace the focused pane, or split it), closing a pane and handing focus to
* the pane that takes its place, swapping/moving panes, resizing a split,
* pruning panes whose run is gone, the rectangles the grid draws, and the
* pruning panes whose run is gone, the rectangles the grid draws, the TABS
* that group up to four panes each (a session in exactly one), and the
* per-workspace storage that must survive bad or stale data.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, beforeEach } from "vitest";
import {
type PaneGroups,
type PaneLayout,
type PaneNode,
EMPTY_GROUPS,
MAX_PANES,
closeInLayout,
detachSlot,
layoutRects,
leaves,
loadLayout,
loadGroups,
moveInLayout,
openInGroups,
openInLayout,
pane,
parseGroups,
parseLayout,
pruneGroups,
pruneLayout,
saveLayout,
removeSlot,
reorderGroups,
saveGroups,
setRatio,
updateActive,
split,
swapInLayout,
} from "../paneLayout";
@@ -182,9 +192,112 @@ describe("pruneLayout", () => {
});
});
describe("pane groups (tabs of up to four panes)", () => {
const single = (slot: number): PaneLayout => ({ root: pane(slot), focus: slot });
const g2: PaneGroups = { groups: [single(0), single(1)], active: 0 };
it("opens a new session as a tab of its own after the active one", () => {
const out = openInGroups(g2, 5, false);
expect(out.groups.map((g) => leaves(g.root))).toEqual([[0], [5], [1]]);
expect(out.active).toBe(1);
});
it("activates the tab already holding a session, focusing it", () => {
const g: PaneGroups = { groups: [single(0), { root: split("row", pane(1), pane(2)), focus: 1 }], active: 0 };
const out = openInGroups(g, 2, false);
expect(out.active).toBe(1);
expect(out.groups[1]!.focus).toBe(2);
});
it("a split open joins the active tab, and the tab it left goes", () => {
const out = openInGroups(g2, 1, true);
expect(out.groups).toHaveLength(1);
expect(leaves(out.groups[0]!.root)).toEqual([0, 1]);
expect(out.groups[0]!.focus).toBe(1);
});
it("a split open on a full tab opens its own tab instead", () => {
let g: PaneGroups = { groups: [single(0)], active: 0 };
for (const s of [1, 2, 3]) g = openInGroups(g, s, true);
expect(leaves(g.groups[0]!.root)).toHaveLength(MAX_PANES);
const out = openInGroups(g, 4, true);
expect(out.groups.map((x) => leaves(x.root))).toEqual([[0, 1, 2, 3], [4]]);
expect(out.active).toBe(1);
});
it("a pane leaving the active tab becomes a tab of its own next to it", () => {
const g: PaneGroups = {
groups: [{ root: split("row", pane(0), pane(1)), focus: 1 }, single(2)],
active: 0,
};
const out = updateActive(g, (l) => closeInLayout(l, 1));
expect(out.groups.map((x) => leaves(x.root))).toEqual([[0], [1], [2]]);
expect(out.active).toBe(0);
});
it("a pane moved in from another tab leaves that tab", () => {
const g: PaneGroups = { groups: [single(0), single(1), single(2)], active: 0 };
const out = updateActive(g, (l) => moveInLayout(l, 2, 0, "bottom"));
expect(out.groups.map((x) => leaves(x.root))).toEqual([[0, 2], [1]]);
expect(out.active).toBe(0);
});
it("an ended session leaves its tab; an emptied active tab hands over to a neighbour", () => {
const g: PaneGroups = { groups: [single(0), single(1), single(2)], active: 2 };
expect(removeSlot(g, 2)).toEqual({ groups: [single(0), single(1)], active: 1 });
expect(removeSlot({ ...g, active: 0 }, 0)).toEqual({ groups: [single(1), single(2)], active: 0 });
expect(removeSlot(g, 7)).toBe(g);
});
it("prunes dead panes and gives every live session without a tab one", () => {
const g: PaneGroups = { groups: [{ root: split("row", pane(0), pane(1)), focus: 1 }, single(2)], active: 1 };
const out = pruneGroups(g, new Set([0, 4]), new Set());
expect(out.groups.map((x) => leaves(x.root))).toEqual([[0], [4]]);
expect(out.active).toBe(0);
expect(pruneGroups(g, new Set([0, 1, 2]), new Set())).toBe(g);
});
it("detaches a pane into a tab of its own at a given place, and reorders tabs", () => {
const g: PaneGroups = { groups: [{ root: split("row", pane(0), pane(1)), focus: 0 }, single(2)], active: 1 };
const out = detachSlot(g, 1, 0);
expect(out.groups.map((x) => leaves(x.root))).toEqual([[1], [0], [2]]);
expect(out.active).toBe(0);
const moved = reorderGroups(out, 2, 0);
expect(moved.groups.map((x) => leaves(x.root))).toEqual([[2], [1], [0]]);
expect(moved.active).toBe(1);
});
});
describe("parseLayout / storage", () => {
beforeEach(() => window.localStorage.clear());
it("stores tabs per workspace and reads a pre-tab single layout as one tab", () => {
const g: PaneGroups = { groups: [{ root: split("row", pane(0), pane(9)), focus: 9 }, { root: pane(4), focus: 4 }], active: 1 };
saveGroups(1, g);
expect(loadGroups(1)).toEqual(g);
window.localStorage.setItem(
"agenthub.workspace.paneLayout",
JSON.stringify({ 2: { root: split("row", pane(0), pane(1)), focus: 1 } })
);
expect(loadGroups(2)).toEqual({ groups: [{ root: split("row", pane(0), pane(1)), focus: 1 }], active: 0 });
});
it("drops a slot past nine and a session stored in two tabs", () => {
expect(parseGroups({ groups: [{ root: pane(10), focus: 10 }], active: 0 })).toEqual(EMPTY_GROUPS);
const out = parseGroups({ groups: [{ root: pane(1), focus: 1 }, { root: pane(1), focus: 1 }], active: 1 });
expect(out).toEqual({ groups: [{ root: pane(1), focus: 1 }], active: 0 });
});
it("keeps the stored active tab selected when a tab before it is dropped", () => {
const b = { root: pane(2), focus: 2 };
const c = { root: pane(3), focus: 3 };
expect(parseGroups({ groups: [{ root: pane(10), focus: 10 }, b, c], active: 1 })).toEqual({
groups: [b, c],
active: 0,
});
expect(parseGroups({ groups: [b, c], active: 7 }).active).toBe(1);
});
it("accepts a valid stored layout", () => {
const l: PaneLayout = { root: split("col", pane(1), pane(3), 0.3), focus: 3 };
expect(parseLayout(JSON.parse(JSON.stringify(l)))).toEqual(l);
@@ -192,7 +305,7 @@ describe("parseLayout / storage", () => {
it("rejects junk, out-of-range slots, duplicates and oversized trees", () => {
expect(parseLayout(null)).toEqual({ root: null, focus: null });
expect(parseLayout({ root: { type: "pane", slot: 7 }, focus: 7 }).root).toBeNull();
expect(parseLayout({ root: { type: "pane", slot: 10 }, focus: 10 }).root).toBeNull();
expect(parseLayout({ root: split("row", pane(1), pane(1)), focus: 1 }).root).toBeNull();
const five = split(
"row",
@@ -208,16 +321,16 @@ describe("parseLayout / storage", () => {
expect(out.focus).toBe(0);
});
it("keeps one layout per workspace", () => {
saveLayout(1, { root: split("row", pane(0), pane(1)), focus: 1 });
saveLayout(2, one);
expect(loadLayout(1).focus).toBe(1);
expect(loadLayout(2)).toEqual(one);
expect(loadLayout(3)).toEqual({ root: null, focus: null });
it("keeps one set of tabs per workspace", () => {
saveGroups(1, { groups: [{ root: split("row", pane(0), pane(1)), focus: 1 }], active: 0 });
saveGroups(2, { groups: [one], active: 0 });
expect(loadGroups(1).groups[0]?.focus).toBe(1);
expect(loadGroups(2)).toEqual({ groups: [one], active: 0 });
expect(loadGroups(3)).toEqual(EMPTY_GROUPS);
});
it("survives unreadable storage", () => {
window.localStorage.setItem("agenthub.workspace.paneLayout", "{not json");
expect(loadLayout(1)).toEqual({ root: null, focus: null });
expect(loadGroups(1)).toEqual(EMPTY_GROUPS);
});
});
+39 -1
View File
@@ -1380,13 +1380,15 @@ export const api = {
*
* @returns {@link CostResult} — the aggregate cost breakdown across sessions.
*/
totalCost: (opts: { lane?: number } = {}) => {
totalCost: (opts: { lane?: number; date?: string } = {}) => {
// Scope the aggregate to the active data-scope, exactly like the sessions /
// stats / analytics endpoints — otherwise switching the Data scope selector
// left the Dashboard "total cost" showing the un-narrowed global total.
// `lane`: only the sessions that workspace owns.
const qs = new URLSearchParams({ tz_offset: String(new Date().getTimezoneOffset()) });
if (opts.lane !== undefined) qs.set("lane", String(opts.lane));
// `date` (local YYYY-MM-DD): only that day's usage, breakdown included.
if (opts.date) qs.set("date", opts.date);
applyScope(qs);
return request<CostResult>(`/pricing/cost?${qs.toString()}`);
},
@@ -2176,6 +2178,14 @@ export const api = {
run: {
/** GET /api/run - lanes with a live tmux-backed run, computed fresh from tmux state. */
list: () => request<RunListResponse>("/run"),
/** GET /api/run/history - persisted runs, newest first; `laneId` narrows to
* one workspace and `tool` (with it) to one tool's — `shell` = Terminal tabs. */
history: (opts: { laneId: number; tool?: string; limit?: number }) => {
const qs = new URLSearchParams({ laneId: String(opts.laneId) });
if (opts.tool) qs.set("tool", opts.tool);
if (opts.limit) qs.set("limit", String(opts.limit));
return request<{ items: RunHistoryItem[] }>(`/run/history?${qs.toString()}`);
},
/** GET /api/run/binary - whether the lane tool's CLI was found on PATH (fresh look). */
binary: (tool: LaneTool = "claude") =>
request<{ found: boolean; path: string | null }>(`/run/binary?tool=${tool}`),
@@ -2607,6 +2617,20 @@ export const api = {
parent: string | null;
entries: { name: string; path: string; isGitRepo: boolean }[];
}>(`/lanes/browse${path ? `?path=${encodeURIComponent(path)}` : ""}`),
/**
* POST /api/lanes/pick-path — open the OS's own folder/file picker on the
* server's desktop and wait for the user. Throws `.code` `EREMOTE` (not a
* browser on the server's machine), `EBADORIGIN` or `ENODIALOG` (no desktop
* / no picker program) when the caller should fall back to {@link browse}.
* @param kind `dir` or `file`.
* @param start Folder (or file) to open the picker at; home otherwise.
* @returns `{ path }`, or `{ cancelled: true }` when the user cancelled.
*/
pickPath: (kind: "dir" | "file", start?: string) =>
request<{ path?: string; cancelled?: boolean }>("/lanes/pick-path", {
method: "POST",
body: JSON.stringify({ kind, start }),
}),
/**
* GET /api/lanes/:id — fetch one lane.
* @param id The lane id.
@@ -2666,6 +2690,9 @@ export const api = {
*/
gitFile: (id: GitTarget, path: string, rev = "work") =>
request<LaneFileContent>(gitUrl(id, "/file", new URLSearchParams({ path, rev }))),
/** URL of GET /api/lanes/:id/git/raw — an image's bytes, for an `<img>`. */
gitRawUrl: (id: GitTarget, path: string, rev = "work") =>
`${BASE}${gitUrl(id, "/raw", new URLSearchParams({ path, rev }))}`,
/**
* PUT /api/lanes/:id/git/file — save the editor's text over the working
* file. `baseSha` is the `sha` it was opened at; a file that changed on
@@ -4011,6 +4038,17 @@ export interface RunListResponse {
items: RunHandle[];
}
/** One row of GET /api/run/history (`dashboard_runs`; only the fields read). */
export interface RunHistoryItem {
id: string;
tool: string;
status: string;
started_at: string;
ended_at: string | null;
/** The run's tmux pane is still up. */
isLive: boolean;
}
/** One suggested working directory for the Run page's cwd picker. */
export interface CwdSuggestion {
/** "dashboard" = this server's own cwd; "home" = user's home dir; "recent"
+21 -2
View File
@@ -1,7 +1,9 @@
/**
* @file laneOrderStorage.ts
* @description Persists the Workspace lane strip's user-chosen card order to
* localStorage, the same read/validate/fallback convention as useTheme.ts.
* @description Persists the sidebar's user-chosen workspace order to
* localStorage, the same read/validate/fallback convention as useTheme.ts, and
* holds the drag-to-reorder math the sidebar and the console tab bar share
* (the tab bar's own order lives with its tabs, `lib/paneLayout.ts`).
*
* The order lives in the browser rather than the lanes table on purpose: it is
* a per-viewer preference, and nothing
@@ -66,3 +68,20 @@ export function moveLane(ids: number[], from: number, to: number): number[] {
next.splice(to, 0, ...next.splice(from, 1));
return next;
}
/** Where a drop lands: before the first item whose middle (`mids`, on the
* drag axis) lies past the pointer, else at the end. Gaps between items —
* a workspace's session list, the space between two tabs — count too. */
export function insertionIndex(mids: number[], pos: number): number {
const i = mids.findIndex((m) => pos < m);
return i === -1 ? mids.length : i;
}
/** Moves the item at `from` to just before insertion point `before`. */
export function moveBefore<T>(ids: T[], from: number, before: number): T[] {
if (from < 0 || from >= ids.length || before < 0 || before > ids.length) return ids;
if (before === from || before === from + 1) return ids;
const next = [...ids];
next.splice(before > from ? before - 1 : before, 0, ...next.splice(from, 1));
return next;
}
+163 -10
View File
@@ -1,20 +1,29 @@
/**
* @file paneLayout.ts
* @description The session view's split panes, VS Code style: a binary tree
* whose leaves are pane SLOTS (0–3, one tmux session each — see
* whose leaves are pane SLOTS (0–9, one tmux session each — see
* `server/lib/pty-run.js`) and whose inner nodes split their box in a row
* (side by side) or a column (stacked) at a ratio. Pure functions only: the
* page keeps the layout in state, and `layoutRects` turns it into the
* rectangles `PaneGrid` draws every pane at — each pane is an absolutely
* placed sibling, so reshaping the tree never re-parents (and so never
* remounts) a live terminal. A slot appears at most once: one terminal is
* never attached twice. The layout is a viewer preference, kept per workspace
* in `localStorage` (`agenthub.workspace.paneLayout`), never on the server.
* never attached twice.
*
* The session view's TABS are groups of such trees (`PaneGroups`): each tab
* holds one to `MAX_PANES` panes, every live session sits in exactly one tab,
* and a session on its own is a one-pane tab — splitting or dropping one tab
* onto another's pane merges them into one tab, closing a pane of a tab puts
* its session back in a tab of its own. The tabs are a viewer preference,
* kept per workspace in `localStorage` (`agenthub.workspace.paneLayout`; a
* stored pre-tab single layout reads as one tab), never on the server.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/** As many panes as a workspace has tmux slots. */
/** Panes in one tab. */
export const MAX_PANES = 4;
/** Sessions a workspace may hold: slots 0–9 (`MAX_PANE_SLOT` + 1 on the server). */
export const MAX_SLOTS = 10;
const MIN_RATIO = 0.1;
const STORAGE_KEY = "agenthub.workspace.paneLayout";
@@ -202,7 +211,7 @@ function parseNode(raw: unknown): PaneNode | null {
if (!raw || typeof raw !== "object") return null;
const n = raw as Record<string, unknown>;
if (n.type === "pane") {
return Number.isInteger(n.slot) && (n.slot as number) >= 0 && (n.slot as number) < MAX_PANES
return Number.isInteger(n.slot) && (n.slot as number) >= 0 && (n.slot as number) < MAX_SLOTS
? pane(n.slot as number)
: null;
}
@@ -225,6 +234,150 @@ export function parseLayout(raw: unknown): PaneLayout {
return { root, focus: focusOf({ root, focus: typeof focus === "number" ? focus : null }) };
}
// ── Tabs: groups of up to MAX_PANES panes ─────────────────────────────
/** The tabs, each a split tree, and the one on screen. */
export interface PaneGroups {
groups: PaneLayout[];
active: number;
}
export const EMPTY_GROUPS: PaneGroups = { groups: [], active: 0 };
const solo = (slot: number): PaneLayout => ({ root: pane(slot), focus: slot });
/** The tab on screen (an empty layout when there is none). */
export const activeLayout = (g: PaneGroups): PaneLayout => g.groups[g.active] ?? EMPTY;
/** The tab holding `slot`, or -1. */
export const groupOf = (g: PaneGroups, slot: number): number =>
g.groups.findIndex((l) => leaves(l.root).includes(slot));
/** Drops emptied tabs. The active tab stays active; when it went, the tab
* before it takes over (the first one when it was first). */
function compact(groups: PaneLayout[], active: number): PaneGroups {
const kept = groups.filter((l) => l.root);
if (!kept.length) return EMPTY_GROUPS;
if (groups[active]?.root) return { groups: kept, active: kept.indexOf(groups[active]) };
const before = groups.slice(0, active).filter((l) => l.root).length;
return { groups: kept, active: Math.max(0, Math.min(before - 1, kept.length - 1)) };
}
/**
* Changes the tab on screen with a per-tree operation (`moveInLayout`,
* `swapInLayout`, `closeInLayout`, `setRatio`…). A session the change brings
* in leaves the tab it was in; one it takes out gets a tab of its own next to
* this one — closing a pane never hides a running session.
*/
export function updateActive(g: PaneGroups, fn: (l: PaneLayout) => PaneLayout): PaneGroups {
const base = g.groups.length ? g : { groups: [EMPTY], active: 0 };
const cur = base.groups[base.active] ?? EMPTY;
const next = fn(cur);
if (next === cur) return g;
const before = leaves(cur.root);
const after = leaves(next.root);
const added = after.filter((s) => !before.includes(s));
const solos = before.filter((s) => !after.includes(s)).map(solo);
const groups = base.groups.map((l, i) =>
i === base.active ? next : added.reduce((acc, s) => closeInLayout(acc, s), l)
);
// An emptied tab is replaced by what left it, in its place.
if (next.root) groups.splice(base.active + 1, 0, ...solos);
else groups.splice(base.active, 1, ...solos);
return compact(groups, base.active);
}
/**
* Shows `slot`. Its own tab is activated (and the slot focused there); a
* session in no tab opens a new one after the active tab. `split` (Ctrl/Cmd+
* click) brings it into the active tab beside the focused pane instead —
* unless that tab is already full, when it falls back to the plain open.
*/
export function openInGroups(g: PaneGroups, slot: number, split: boolean): PaneGroups {
const cur = activeLayout(g);
if (split && cur.root && (leaves(cur.root).includes(slot) || leaves(cur.root).length < MAX_PANES))
return updateActive(g, (l) => openInLayout(l, slot, "split"));
const at = groupOf(g, slot);
if (at >= 0) {
const groups = [...g.groups];
groups[at] = { ...(groups[at] as PaneLayout), focus: slot };
return { groups, active: at };
}
const i = g.groups.length ? g.active + 1 : 0;
const groups = [...g.groups];
groups.splice(i, 0, solo(slot));
return { groups, active: i };
}
/** A session ended: its pane goes, and its tab when that was the last one. */
export function removeSlot(g: PaneGroups, slot: number): PaneGroups {
const at = groupOf(g, slot);
if (at < 0) return g;
const groups = [...g.groups];
groups[at] = closeInLayout(groups[at] as PaneLayout, slot);
return compact(groups, g.active);
}
/** Takes `slot` out of its tab into a tab of its own, inserted before the tab
* now at `at` (`groups.length` = last), and shows it. */
export function detachSlot(g: PaneGroups, slot: number, at: number): PaneGroups {
const from = groupOf(g, slot);
if (from < 0) return g;
const groups = g.groups.map((l, i) => (i === from ? closeInLayout(l, slot) : l));
groups.splice(at, 0, solo(slot));
return compact(groups, at);
}
/** Moves the tab at `from` before the one at `at`; the active tab stays active. */
export function reorderGroups(g: PaneGroups, from: number, at: number): PaneGroups {
if (from < 0 || from === at || from + 1 === at) return g;
const groups = [...g.groups];
const [moved] = groups.splice(from, 1);
groups.splice(at > from ? at - 1 : at, 0, moved as PaneLayout);
return { groups, active: groups.indexOf(g.groups[g.active] as PaneLayout) };
}
/**
* Drops every pane whose session is neither `live` nor `keep` (see
* `pruneLayout`) and the tabs that leaves empty, and gives every live session
* no tab holds one of its own. Same object back when nothing changed.
*/
export function pruneGroups(g: PaneGroups, live: Set<number>, keep: Set<number>): PaneGroups {
let changed = false;
const groups = g.groups.map((l) => {
const n = pruneLayout(l, live, keep);
if (n !== l) changed = true;
return n;
});
const tabbed = new Set(groups.flatMap((l) => leaves(l.root)));
const missing = [...live].filter((s) => !tabbed.has(s)).sort((a, b) => a - b);
if (!changed && !missing.length) return g;
return compact([...groups, ...missing.map(solo)], g.active);
}
/** Stored tabs, checked: a malformed tab, or one showing a session an
* earlier tab already holds, is dropped. A pre-tab single layout is one tab. */
export function parseGroups(raw: unknown): PaneGroups {
if (!raw || typeof raw !== "object") return EMPTY_GROUPS;
const r = raw as { groups?: unknown; active?: unknown };
const list = Array.isArray(r.groups) ? r.groups : "root" in r ? [raw] : [];
const rawActive = Number.isInteger(r.active) ? (r.active as number) : 0;
const seen = new Set<number>();
const groups: PaneLayout[] = [];
// `active` indexes the STORED list; count only the tabs kept before it, so
// a dropped tab ahead of it does not shift the selection onto its neighbour.
let active = 0;
list.forEach((item, i) => {
const l = parseLayout(item);
const shown = leaves(l.root);
if (!l.root || shown.some((s) => seen.has(s))) return;
shown.forEach((s) => seen.add(s));
if (i < rawActive) active++;
groups.push(l);
});
if (!groups.length) return EMPTY_GROUPS;
return { groups, active: Math.min(active, groups.length - 1) };
}
function readAll(): Record<string, unknown> {
try {
const all = JSON.parse(window.localStorage.getItem(STORAGE_KEY) ?? "{}");
@@ -234,17 +387,17 @@ function readAll(): Record<string, unknown> {
}
}
export function loadLayout(laneId: number): PaneLayout {
return parseLayout(readAll()[String(laneId)]);
export function loadGroups(laneId: number): PaneGroups {
return parseGroups(readAll()[String(laneId)]);
}
export function saveLayout(laneId: number, layout: PaneLayout): void {
export function saveGroups(laneId: number, g: PaneGroups): void {
const all = readAll();
if (layout.root) all[String(laneId)] = layout;
if (g.groups.length) all[String(laneId)] = g;
else delete all[String(laneId)];
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(all));
} catch {
/* disabled storage: the layout lives for this page only */
/* disabled storage: the tabs live for this page only */
}
}