Phase 1: tabbed workspaces

Each tab is an independent tile tree; PTYs in non-active tabs keep
running (render-all-panes with visibility:hidden on inactive layers
so xterm.js's fit() still sees valid dimensions and the existing
per-pane resize dedupe absorbs no-op SIGWINCHes).

workspace.json shape goes from a bare TreeNode to
`{ version: 2, workspaces: [{ id, name, tree }] }` with a legacy v1
auto-wrap migration (the old single tree becomes one tab named
"Default").

App.tsx wraps the old single-tree state in workspace-aware state
but keeps `tree` / `setTree` / `activeLeafId` / `setActiveLeafId` as
identity-stable derived wrappers (reading currentWorkspaceId from a
ref), so the bulk of App.tsx stays unchanged.

XtermPane's initial term.focus() now checks `visibility !== "hidden"`
on the container so a pane mounting inside a hidden tab on app boot
doesn't yank focus away from the active tab. The focus poller is
scoped to the active workspace layer for the same reason.

Shortcuts: Ctrl+T new tab, Ctrl+Shift+T close current (window.confirm
when there are live panes), Ctrl+PageDown/PageUp navigate, Ctrl+1..9
switch to tab N. README + help overlay auto-generated from
shortcuts.ts.

79/79 vitest pass (7 new envelope-migration cases). tsc -b clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
megaproxy 2026-05-28 18:43:32 +01:00
parent c92847413b
commit 1a035ad0a6
8 changed files with 933 additions and 52 deletions

View file

@ -21,6 +21,10 @@ import {
MAX_FONT_SIZE,
serialize,
deserialize,
serializeWorkspaces,
deserializeWorkspaces,
singletonEnvelope,
WORKSPACES_VERSION,
presetSingle,
presetTwoColumns,
presetThreeColumns,
@ -665,3 +669,82 @@ describe("serialize / deserialize", () => {
expect(back.sshHostId).toBe("h-1");
});
});
describe("workspaces envelope", () => {
it("roundtrips a multi-workspace envelope", () => {
const env = {
version: WORKSPACES_VERSION,
workspaces: [
{ id: "w1", name: "alpha", tree: newLeaf({ distro: "Ubuntu" }) },
{
id: "w2",
name: "beta",
tree: newSplit("h", newLeaf({ label: "left" }), newLeaf()),
},
],
};
const back = deserializeWorkspaces(serializeWorkspaces(env));
expect(back).toEqual(env);
});
it("returns null on invalid JSON", () => {
expect(deserializeWorkspaces("not json")).toBeNull();
});
it("returns null when version is wrong or workspaces is missing", () => {
expect(deserializeWorkspaces('{"version": 99, "workspaces": []}')).toBeNull();
expect(deserializeWorkspaces('{"version": 2}')).toBeNull();
});
it("returns null when an envelope has zero valid workspaces", () => {
expect(
deserializeWorkspaces('{"version": 2, "workspaces": [{"id": 1}]}'),
).toBeNull();
});
it("migrates a legacy v1 bare-tree JSON into a single 'Default' workspace", () => {
const legacy = JSON.stringify({
kind: "split",
id: "s1",
orientation: "h",
ratio: 0.5,
a: { kind: "leaf", id: "a", distro: "Ubuntu" },
b: { kind: "leaf", id: "b", distro: "PowerShell" },
});
const env = deserializeWorkspaces(legacy);
expect(env).not.toBeNull();
expect(env!.version).toBe(WORKSPACES_VERSION);
expect(env!.workspaces.length).toBe(1);
expect(env!.workspaces[0].name).toBe("Default");
// Per-leaf legacy migration also applied — PowerShell sentinel mapped.
const tree = env!.workspaces[0].tree as SplitNode;
expect((tree.a as LeafNode).shellKind).toBe("wsl");
expect((tree.b as LeafNode).shellKind).toBe("powershell");
expect((tree.b as LeafNode).distro).toBeUndefined();
});
it("singletonEnvelope wraps a tree with a fresh workspace id", () => {
const t = newLeaf({ label: "only" });
const env = singletonEnvelope(t, "Main");
expect(env.workspaces.length).toBe(1);
expect(env.workspaces[0].name).toBe("Main");
expect(env.workspaces[0].tree).toBe(t);
expect(typeof env.workspaces[0].id).toBe("string");
expect(env.workspaces[0].id).not.toBe(t.id);
});
it("skips malformed workspaces but keeps the valid ones", () => {
const env = {
version: WORKSPACES_VERSION,
workspaces: [
{ id: "ok", name: "alpha", tree: { kind: "leaf", id: "L" } },
{ id: 42, name: "bad-id", tree: { kind: "leaf", id: "L2" } },
{ id: "no-tree", name: "still-bad" },
],
};
const back = deserializeWorkspaces(JSON.stringify(env));
expect(back).not.toBeNull();
expect(back!.workspaces.length).toBe(1);
expect(back!.workspaces[0].id).toBe("ok");
});
});

View file

@ -703,6 +703,92 @@ export function deserialize(json: string): TreeNode | null {
}
}
// ---- workspaces envelope ---------------------------------------------------
/** One named tab in the tab strip. Each workspace owns its own tile tree;
* leaf NodeIds remain globally unique across workspaces so the app-level
* paneIdByLeaf map continues to work without partitioning. */
export interface Workspace {
id: NodeId;
name: string;
tree: TreeNode;
}
/** Top-level persistence shape. `version` bumps when the envelope schema
* changes; the v1 shape was a bare TreeNode at the JSON root, migrated
* automatically by {@link deserializeWorkspaces}. */
export interface WorkspacesEnvelope {
version: 2;
workspaces: Workspace[];
}
export const WORKSPACES_VERSION = 2 as const;
/** Construct an envelope wrapping a single workspace with the given tree.
* Used for first-launch and as the destination of the v1v2 migration. */
export function singletonEnvelope(tree: TreeNode, name = "Default"): WorkspacesEnvelope {
return {
version: WORKSPACES_VERSION,
workspaces: [{ id: newId(), name, tree }],
};
}
export function serializeWorkspaces(env: WorkspacesEnvelope): string {
return JSON.stringify(env);
}
/** Parse a persisted workspaces envelope. Accepts:
* - Current shape: `{ version: 2, workspaces: [{ id, name, tree }] }`
* - Legacy v1 shape: a bare {@link TreeNode} wrapped as one workspace
* named "Default" with a fresh id.
* Per-leaf legacy migrations ({@link migrateLegacyLeaves}) still apply to
* each workspace's tree. Returns null when the JSON is unrecognisable. */
export function deserializeWorkspaces(json: string): WorkspacesEnvelope | null {
let parsed: unknown;
try {
parsed = JSON.parse(json);
} catch {
return null;
}
// v1: bare TreeNode at the root
if (isTreeNode(parsed)) {
return singletonEnvelope(migrateLegacyLeaves(parsed));
}
// v2: envelope
if (
typeof parsed === "object" &&
parsed !== null &&
(parsed as { version?: unknown }).version === WORKSPACES_VERSION &&
Array.isArray((parsed as { workspaces?: unknown }).workspaces)
) {
const raw = (parsed as { workspaces: unknown[] }).workspaces;
const workspaces: Workspace[] = [];
for (const w of raw) {
if (
typeof w !== "object" ||
w === null ||
typeof (w as { id?: unknown }).id !== "string" ||
typeof (w as { name?: unknown }).name !== "string" ||
!isTreeNode((w as { tree?: unknown }).tree)
) {
continue;
}
const tw = w as { id: string; name: string; tree: TreeNode };
workspaces.push({
id: tw.id,
name: tw.name,
tree: migrateLegacyLeaves(tw.tree),
});
}
if (workspaces.length === 0) return null;
return { version: WORKSPACES_VERSION, workspaces };
}
return null;
}
/** Sentinel used in pre-shellKind workspaces to mark PowerShell panes. */
const LEGACY_POWERSHELL_DISTRO = "PowerShell";