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:
parent
c92847413b
commit
1a035ad0a6
8 changed files with 933 additions and 52 deletions
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue