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");
});
});