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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 v1→v2 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";
|
||||
|
||||
|
|
|
|||
|
|
@ -30,6 +30,21 @@ export const SHORTCUT_SECTIONS: ShortcutSection[] = [
|
|||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Tabs",
|
||||
items: [
|
||||
{ keys: "Ctrl+T", description: "New tab (blank workspace, one pane)" },
|
||||
{
|
||||
keys: "Ctrl+Shift+T",
|
||||
description: "Close current tab (confirms when the tab has live panes)",
|
||||
},
|
||||
{
|
||||
keys: "Ctrl+PageDown / Ctrl+PageUp",
|
||||
description: "Switch to next / previous tab",
|
||||
},
|
||||
{ keys: "Ctrl+1 … Ctrl+9", description: "Switch to tab 1 … 9" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Navigation",
|
||||
items: [
|
||||
|
|
@ -108,6 +123,10 @@ export const TIPS: TipSpec[] = [
|
|||
title: "Workspace persistence",
|
||||
body: "Layout, labels, distro choices, and SSH hosts auto-save to %APPDATA%/com.megaproxy.tiletopia (debounced 500ms). Closed panes don't come back — only the structure is restored, shells spawn fresh on next launch.",
|
||||
},
|
||||
{
|
||||
title: "Tabs (workspaces)",
|
||||
body: "Each tab is an independent tile layout — useful for keeping one tab per project. PTYs in non-active tabs keep running (a Claude session in tab A keeps going while you work in tab B). New tab starts with one default-shell pane; close confirms when the tab has live panes. Tabs auto-save to the same workspace.json.",
|
||||
},
|
||||
{
|
||||
title: "MCP server (let Claude drive the workspace)",
|
||||
body: "Titlebar 🤖 opens the MCP control panel. Start the server, then for Claude Desktop click 'Download .mcpb' and drag the file into Settings → Extensions — zero-config because the bundle reads your bearer token from %APPDATA% at launch (no copy-paste, survives token rotation). For Claude Code (terminal CLI) use the fallback snippet in the panel: it wires npx mcp-remote as a stdio shim because Claude Code's HTTP-MCP client ignores static bearer auth and tries OAuth instead. URL + token persist across restarts; Regenerate the token in the panel if it leaks. Default-deny per pane: toggle 🤖 on each pane's toolbar to expose it to MCP.",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue