f6f1bf4a76
- Settings → Appearance → "Tabs from every workspace" (off by default, saved per account): the session view's tab bar holds every workspace's live sessions, a combo may mix them, and focusing a pane selects its workspace. Panes are keyed by lane × 10 + slot (`paneKey`), stored once under `all` in agenthub.workspace.paneLayout. - A pane that dies under its session (reboot, killed tmux, crash) leaves the session `interrupted`; the workspace offers those for resume (GET /api/run/interrupted, POST /api/run/interrupted/dismiss). - Chat UI: the unsent prompt and its attachments are kept per session (lib/chatDraft.ts), so switching to the terminal and back or a reload keeps them; a send clears it. Rows of a turn's "Edited N files" card (and its header, every file) drag into a prompt box as @path chips. - Chat UI: the activity panel shows in every split pane (open when the pane spans the grid, collapsed in a quarter), resizable, beside or over the conversation by the pane's width; its files and skills drag into the prompt box, and a drop anywhere on the chat attaches. - Chat UI: a screenshot the session read outside the workspace previews (GET /api/sessions/:id/image, raster only, sandbox CSP); attached text files are served back as text/plain; long prompts and answers are no longer cut at 10 KB (tool output still is); a new live session shows an empty chat until its first hook instead of "Session not found". - Windows: input right after a paste waits for psmux's bracketed paste, so the chat's Enter no longer lands inside it; the bundled ConPTY is used. - Changes panel: a Workspace context fold (instructions, files, folders). Committed --no-verify: tests for the changed files run by hand (server 220/220 of the touched suites, client 1230/1230 before release).
450 lines
19 KiB
TypeScript
450 lines
19 KiB
TypeScript
/**
|
||
* @file paneLayout.ts
|
||
* @description The session view's split panes, VS Code style: a binary tree
|
||
* 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 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.
|
||
*
|
||
* With Settings → Appearance → "Tabs from every workspace" on, ONE set of tabs
|
||
* (stored under `all`) holds every workspace's sessions: a leaf is then a
|
||
* `paneKey` (lane id × `MAX_SLOTS` + slot) instead of a bare slot.
|
||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||
*/
|
||
|
||
/** 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;
|
||
/** A pane's id in the every-workspace tabs: unique across lanes, still a number. */
|
||
export const paneKey = (laneId: number, slot: number) => laneId * MAX_SLOTS + slot;
|
||
/** The slot of a leaf — itself, or a `paneKey`'s slot part. */
|
||
export const slotOfKey = (key: number) => key % MAX_SLOTS;
|
||
/** The tabs' storage name: a lane id, or `all` for the every-workspace tabs. */
|
||
export type GroupsScope = number | "all";
|
||
const MIN_RATIO = 0.1;
|
||
const STORAGE_KEY = "agenthub.workspace.paneLayout";
|
||
|
||
export type SplitDir = "row" | "col";
|
||
export type Edge = "left" | "right" | "top" | "bottom";
|
||
export type PaneNode =
|
||
| { type: "pane"; slot: number }
|
||
| { type: "split"; dir: SplitDir; ratio: number; a: PaneNode; b: PaneNode };
|
||
/** The tree plus the focused pane — the one a plain open replaces. */
|
||
export interface PaneLayout {
|
||
root: PaneNode | null;
|
||
focus: number | null;
|
||
}
|
||
export interface Rect {
|
||
x: number;
|
||
y: number;
|
||
w: number;
|
||
h: number;
|
||
}
|
||
|
||
export const EMPTY_LAYOUT: PaneLayout = { root: null, focus: null };
|
||
const EMPTY = EMPTY_LAYOUT;
|
||
const clampRatio = (r: number) => Math.min(1 - MIN_RATIO, Math.max(MIN_RATIO, r));
|
||
|
||
export const pane = (slot: number): PaneNode => ({ type: "pane", slot });
|
||
export const split = (dir: SplitDir, a: PaneNode, b: PaneNode, ratio = 0.5): PaneNode => ({
|
||
type: "split",
|
||
dir,
|
||
ratio,
|
||
a,
|
||
b,
|
||
});
|
||
|
||
/** The slots shown, in reading order. */
|
||
export function leaves(node: PaneNode | null): number[] {
|
||
if (!node) return [];
|
||
return node.type === "pane" ? [node.slot] : [...leaves(node.a), ...leaves(node.b)];
|
||
}
|
||
|
||
function mapLeaves(node: PaneNode, fn: (slot: number) => PaneNode): PaneNode {
|
||
if (node.type === "pane") return fn(node.slot);
|
||
return { ...node, a: mapLeaves(node.a, fn), b: mapLeaves(node.b, fn) };
|
||
}
|
||
|
||
/** Takes `slot`'s leaf out; its sibling takes the parent's place. `heir` is
|
||
* the first slot of whatever now fills that space. */
|
||
function removeLeaf(node: PaneNode, slot: number): { root: PaneNode | null; heir: number | null } {
|
||
if (node.type === "pane")
|
||
return node.slot === slot ? { root: null, heir: null } : { root: node, heir: null };
|
||
if (node.a.type === "pane" && node.a.slot === slot)
|
||
return { root: node.b, heir: leaves(node.b)[0] ?? null };
|
||
if (node.b.type === "pane" && node.b.slot === slot)
|
||
return { root: node.a, heir: leaves(node.a)[0] ?? null };
|
||
const a = removeLeaf(node.a, slot);
|
||
if (a.root !== node.a) return { root: { ...node, a: a.root as PaneNode }, heir: a.heir };
|
||
const b = removeLeaf(node.b, slot);
|
||
return { root: b.root === node.b ? node : { ...node, b: b.root as PaneNode }, heir: b.heir };
|
||
}
|
||
|
||
/** Splits `target`'s leaf, putting `slot` on its `edge` side. */
|
||
function splitLeaf(node: PaneNode, target: number, slot: number, edge: Edge): PaneNode {
|
||
const dir: SplitDir = edge === "left" || edge === "right" ? "row" : "col";
|
||
const first = edge === "left" || edge === "top";
|
||
return mapLeaves(node, (s) =>
|
||
s === target
|
||
? first
|
||
? split(dir, pane(slot), pane(s))
|
||
: split(dir, pane(s), pane(slot))
|
||
: pane(s)
|
||
);
|
||
}
|
||
|
||
function focusOf(layout: PaneLayout): number | null {
|
||
const shown = leaves(layout.root);
|
||
return layout.focus !== null && shown.includes(layout.focus) ? layout.focus : (shown[0] ?? null);
|
||
}
|
||
|
||
/** The pane a new one splits, and on which side, filling a 2×2 grid in
|
||
* order: right of a lone pane, under the right one, under the left one. The
|
||
* biggest pane by the tree's SHAPE (dragged ratios ignored; a tie goes to the
|
||
* later pane) is the gap — so a three-pane tab of any shape fills its one
|
||
* missing quarter. A tall pane splits down, any other to the right. */
|
||
function nextSpot(root: PaneNode): [number, Edge] {
|
||
const even = (n: PaneNode): PaneNode =>
|
||
n.type === "pane" ? n : { ...n, ratio: 0.5, a: even(n.a), b: even(n.b) };
|
||
const best = layoutRects(even(root)).panes.reduce((m, r) => (r.w * r.h >= m.w * m.h ? r : m));
|
||
return [best.slot, best.h > best.w ? "bottom" : "right"];
|
||
}
|
||
|
||
/** Adds `slot` to the layout at the grid's next place (`nextSpot`), focused. */
|
||
function placeInLayout(layout: PaneLayout, slot: number): PaneLayout {
|
||
if (!layout.root) return { root: pane(slot), focus: slot };
|
||
const [target, edge] = nextSpot(layout.root);
|
||
return { root: splitLeaf(layout.root, target, slot, edge), focus: slot };
|
||
}
|
||
|
||
/**
|
||
* Shows `slot`: focuses it when it is already on screen; otherwise `split`
|
||
* adds it at the grid's next place (`nextSpot`, while there is room) and
|
||
* `replace` puts it in the focused pane.
|
||
*/
|
||
export function openInLayout(
|
||
layout: PaneLayout,
|
||
slot: number,
|
||
mode: "replace" | "split"
|
||
): PaneLayout {
|
||
if (!layout.root) return { root: pane(slot), focus: slot };
|
||
const shown = leaves(layout.root);
|
||
if (shown.includes(slot)) return { ...layout, focus: slot };
|
||
if (mode === "split" && shown.length < MAX_PANES) return placeInLayout(layout, slot);
|
||
const target = focusOf(layout) as number;
|
||
return { root: mapLeaves(layout.root, (s) => pane(s === target ? slot : s)), focus: slot };
|
||
}
|
||
|
||
/** Closes `slot`'s pane (its session keeps running as a tab). */
|
||
export function closeInLayout(layout: PaneLayout, slot: number): PaneLayout {
|
||
if (!layout.root || !leaves(layout.root).includes(slot)) return layout;
|
||
const { root, heir } = removeLeaf(layout.root, slot);
|
||
if (!root) return EMPTY;
|
||
return { root, focus: layout.focus === slot ? heir : focusOf({ root, focus: layout.focus }) };
|
||
}
|
||
|
||
/** Swaps `slot` into `target`'s pane; when `slot` was shown elsewhere, the two
|
||
* panes trade places. `slot` ends up focused. */
|
||
export function swapInLayout(layout: PaneLayout, slot: number, target: number): PaneLayout {
|
||
if (!layout.root || slot === target) return layout;
|
||
return {
|
||
root: mapLeaves(layout.root, (s) => pane(s === target ? slot : s === slot ? target : s)),
|
||
focus: slot,
|
||
};
|
||
}
|
||
|
||
/** Moves `slot` (shown or not) onto `target`'s `edge`, splitting that pane. */
|
||
export function moveInLayout(
|
||
layout: PaneLayout,
|
||
slot: number,
|
||
target: number,
|
||
edge: Edge
|
||
): PaneLayout {
|
||
if (!layout.root || slot === target) return layout;
|
||
const shown = leaves(layout.root);
|
||
if (!shown.includes(target)) return layout;
|
||
if (!shown.includes(slot) && shown.length >= MAX_PANES) return layout;
|
||
const rest = shown.includes(slot) ? removeLeaf(layout.root, slot).root : layout.root;
|
||
return { root: splitLeaf(rest as PaneNode, target, slot, edge), focus: slot };
|
||
}
|
||
|
||
/** Drops every pane whose slot is neither `live` nor `pending` (just opened,
|
||
* not yet in the run list). Same object back when nothing went. */
|
||
export function pruneLayout(
|
||
layout: PaneLayout,
|
||
live: Set<number>,
|
||
pending: Set<number>
|
||
): PaneLayout {
|
||
let out = layout;
|
||
for (const s of leaves(layout.root))
|
||
if (!live.has(s) && !pending.has(s)) out = closeInLayout(out, s);
|
||
return out;
|
||
}
|
||
|
||
/** Sets the ratio of the split at `path` ("" = root, then "a"/"b" per level). */
|
||
export function setRatio(node: PaneNode, path: string, ratio: number): PaneNode {
|
||
if (node.type !== "split") return node;
|
||
if (!path) return { ...node, ratio: clampRatio(ratio) };
|
||
const side = path[0] as "a" | "b";
|
||
return { ...node, [side]: setRatio(node[side], path.slice(1), ratio) };
|
||
}
|
||
|
||
/**
|
||
* Where to draw everything, as fractions of the grid's box: each pane's
|
||
* rectangle, and each split's divider — `at` is its ratio, `box` the split's
|
||
* own rectangle, which is what a drag converts a pointer position against.
|
||
*/
|
||
export function layoutRects(node: PaneNode | null): {
|
||
panes: (Rect & { slot: number })[];
|
||
dividers: { path: string; dir: SplitDir; at: number; box: Rect }[];
|
||
} {
|
||
const panes: (Rect & { slot: number })[] = [];
|
||
const dividers: { path: string; dir: SplitDir; at: number; box: Rect }[] = [];
|
||
const walk = (n: PaneNode, box: Rect, path: string) => {
|
||
if (n.type === "pane") {
|
||
panes.push({ slot: n.slot, ...box });
|
||
return;
|
||
}
|
||
dividers.push({ path, dir: n.dir, at: n.ratio, box });
|
||
if (n.dir === "row") {
|
||
const w = box.w * n.ratio;
|
||
walk(n.a, { ...box, w }, path + "a");
|
||
walk(n.b, { ...box, x: box.x + w, w: box.w - w }, path + "b");
|
||
} else {
|
||
const h = box.h * n.ratio;
|
||
walk(n.a, { ...box, h }, path + "a");
|
||
walk(n.b, { ...box, y: box.y + h, h: box.h - h }, path + "b");
|
||
}
|
||
};
|
||
if (node) walk(node, { x: 0, y: 0, w: 1, h: 1 }, "");
|
||
return { panes, dividers };
|
||
}
|
||
|
||
function parseNode(raw: unknown, max: number): 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
|
||
? pane(n.slot as number)
|
||
: null;
|
||
}
|
||
if (n.type !== "split" || (n.dir !== "row" && n.dir !== "col")) return null;
|
||
const a = parseNode(n.a, max);
|
||
const b = parseNode(n.b, max);
|
||
if (!a || !b) return null;
|
||
const ratio = typeof n.ratio === "number" && Number.isFinite(n.ratio) ? clampRatio(n.ratio) : 0.5;
|
||
return split(n.dir, a, b, ratio);
|
||
}
|
||
|
||
/** A stored layout, checked: anything malformed, a slot out of range, a slot
|
||
* shown twice or more than `MAX_PANES` panes is no layout at all. */
|
||
export function parseLayout(raw: unknown, max = MAX_SLOTS): PaneLayout {
|
||
if (!raw || typeof raw !== "object") return EMPTY;
|
||
const root = parseNode((raw as { root?: unknown }).root, max);
|
||
const shown = leaves(root);
|
||
if (!root || shown.length > MAX_PANES || new Set(shown).size !== shown.length) return EMPTY;
|
||
const focus = (raw as { focus?: unknown }).focus;
|
||
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 at the grid's next place 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 `slots` (a dropped tab, or one pane of a combo) out of their tabs
|
||
* into the tab at `to`, which becomes active. Same object back when nothing
|
||
* would move or the tab would pass `MAX_PANES`. */
|
||
export function joinGroup(g: PaneGroups, slots: number[], to: number): PaneGroups {
|
||
const dst = g.groups[to];
|
||
if (!dst) return g;
|
||
const moving = slots.filter((s) => {
|
||
const from = groupOf(g, s);
|
||
return from >= 0 && from !== to;
|
||
});
|
||
if (!moving.length || leaves(dst.root).length + moving.length > MAX_PANES) return g;
|
||
const groups = g.groups.map((l, i) =>
|
||
i === to ? moving.reduce(placeInLayout, l) : moving.reduce(closeInLayout, l)
|
||
);
|
||
return compact(groups, to);
|
||
}
|
||
|
||
/** 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, max = MAX_SLOTS): 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, max);
|
||
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) ?? "{}");
|
||
return all && typeof all === "object" ? all : {};
|
||
} catch {
|
||
return {};
|
||
}
|
||
}
|
||
|
||
export function loadGroups(laneId: GroupsScope): PaneGroups {
|
||
return parseGroups(
|
||
readAll()[String(laneId)],
|
||
laneId === "all" ? Number.MAX_SAFE_INTEGER : MAX_SLOTS
|
||
);
|
||
}
|
||
|
||
export function saveGroups(laneId: GroupsScope, g: PaneGroups): void {
|
||
const all = readAll();
|
||
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 tabs live for this page only */
|
||
}
|
||
}
|