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:
@@ -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]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
@@ -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 */
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user