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>
750 lines
25 KiB
TypeScript
750 lines
25 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import {
|
|
newLeaf,
|
|
newSplit,
|
|
replaceById,
|
|
splitLeaf,
|
|
closeLeaf,
|
|
findLeaf,
|
|
leafCount,
|
|
walkLeaves,
|
|
changeDistro,
|
|
setLeafShell,
|
|
changeLabel,
|
|
toggleBroadcast,
|
|
toggleMcpAllow,
|
|
adjustFontSize,
|
|
adjustAllFontSizes,
|
|
resolveFontSize,
|
|
DEFAULT_FONT_SIZE,
|
|
MIN_FONT_SIZE,
|
|
MAX_FONT_SIZE,
|
|
serialize,
|
|
deserialize,
|
|
serializeWorkspaces,
|
|
deserializeWorkspaces,
|
|
singletonEnvelope,
|
|
WORKSPACES_VERSION,
|
|
presetSingle,
|
|
presetTwoColumns,
|
|
presetThreeColumns,
|
|
presetTwoRows,
|
|
presetTwoByTwo,
|
|
promoteLeaf,
|
|
type TreeNode,
|
|
type LeafNode,
|
|
type SplitNode,
|
|
} from "./tree";
|
|
|
|
function leafIds(root: TreeNode): string[] {
|
|
return Array.from(walkLeaves(root)).map((l) => l.id);
|
|
}
|
|
|
|
function leafDistros(root: TreeNode): (string | undefined)[] {
|
|
return Array.from(walkLeaves(root)).map((l) => l.distro);
|
|
}
|
|
|
|
describe("newLeaf", () => {
|
|
it("returns a leaf with a unique id, default shellKind=wsl, no other metadata", () => {
|
|
const a = newLeaf();
|
|
const b = newLeaf();
|
|
expect(a.kind).toBe("leaf");
|
|
expect(typeof a.id).toBe("string");
|
|
expect(a.id).not.toEqual(b.id);
|
|
expect(a.shellKind).toBe("wsl");
|
|
expect(a.distro).toBeUndefined();
|
|
expect(a.cwd).toBeUndefined();
|
|
expect(a.sshHostId).toBeUndefined();
|
|
expect(a.label).toBeUndefined();
|
|
expect(a.broadcast).toBeUndefined();
|
|
});
|
|
|
|
it("applies provided props", () => {
|
|
const leaf = newLeaf({ distro: "Ubuntu", cwd: "/home", label: "ml" });
|
|
expect(leaf.distro).toBe("Ubuntu");
|
|
expect(leaf.cwd).toBe("/home");
|
|
expect(leaf.label).toBe("ml");
|
|
});
|
|
|
|
it("respects an explicit non-wsl shellKind", () => {
|
|
const ps = newLeaf({ shellKind: "powershell" });
|
|
expect(ps.shellKind).toBe("powershell");
|
|
const ssh = newLeaf({ shellKind: "ssh", sshHostId: "host-1" });
|
|
expect(ssh.shellKind).toBe("ssh");
|
|
expect(ssh.sshHostId).toBe("host-1");
|
|
});
|
|
});
|
|
|
|
describe("newSplit", () => {
|
|
it("defaults ratio to 0.5", () => {
|
|
const split = newSplit("h", newLeaf(), newLeaf());
|
|
expect(split.ratio).toBe(0.5);
|
|
expect(split.kind).toBe("split");
|
|
expect(split.orientation).toBe("h");
|
|
});
|
|
|
|
it("respects an explicit ratio", () => {
|
|
const split = newSplit("v", newLeaf(), newLeaf(), 0.33);
|
|
expect(split.ratio).toBeCloseTo(0.33);
|
|
});
|
|
});
|
|
|
|
describe("replaceById", () => {
|
|
it("replaces the root if its id matches", () => {
|
|
const a = newLeaf({ label: "old" });
|
|
const next = replaceById(a, a.id, () => newLeaf({ label: "new" }));
|
|
expect((next as LeafNode).label).toBe("new");
|
|
});
|
|
|
|
it("returns the same root reference when no match", () => {
|
|
const a = newSplit("h", newLeaf(), newLeaf());
|
|
const same = replaceById(a, "no-such-id", () => newLeaf());
|
|
expect(same).toBe(a);
|
|
});
|
|
|
|
it("returns a new root when a nested node is replaced (immutability)", () => {
|
|
const target = newLeaf({ label: "target" });
|
|
const sibling = newLeaf({ label: "sibling" });
|
|
const root = newSplit("h", target, sibling);
|
|
const next = replaceById(root, target.id, (n) => ({
|
|
...(n as LeafNode),
|
|
label: "edited",
|
|
}));
|
|
expect(next).not.toBe(root);
|
|
expect((next as SplitNode).b).toBe(sibling); // untouched branch reused
|
|
expect(((next as SplitNode).a as LeafNode).label).toBe("edited");
|
|
});
|
|
});
|
|
|
|
describe("splitLeaf", () => {
|
|
it("replaces a leaf with a horizontal split", () => {
|
|
const leaf = newLeaf({ distro: "Ubuntu" });
|
|
const next = splitLeaf(leaf, leaf.id, "h");
|
|
expect(next.kind).toBe("split");
|
|
const s = next as SplitNode;
|
|
expect(s.orientation).toBe("h");
|
|
expect(s.a).toBe(leaf);
|
|
expect((s.b as LeafNode).kind).toBe("leaf");
|
|
});
|
|
|
|
it("propagates inherited distro / cwd to the new leaf", () => {
|
|
const leaf = newLeaf({ distro: "Ubuntu", cwd: "/projects/x" });
|
|
const next = splitLeaf(leaf, leaf.id, "v", {
|
|
distro: leaf.distro,
|
|
cwd: leaf.cwd,
|
|
});
|
|
const newSide = (next as SplitNode).b as LeafNode;
|
|
expect(newSide.distro).toBe("Ubuntu");
|
|
expect(newSide.cwd).toBe("/projects/x");
|
|
});
|
|
|
|
it("is a no-op when the leaf id is not found", () => {
|
|
const leaf = newLeaf();
|
|
const same = splitLeaf(leaf, "no-such-id", "h");
|
|
expect(same).toBe(leaf);
|
|
});
|
|
|
|
it("preserves siblings when splitting a nested leaf", () => {
|
|
const target = newLeaf();
|
|
const sibling = newLeaf();
|
|
const root = newSplit("h", target, sibling);
|
|
const next = splitLeaf(root, target.id, "v") as SplitNode;
|
|
expect(next.b).toBe(sibling);
|
|
expect(next.a.kind).toBe("split");
|
|
});
|
|
});
|
|
|
|
describe("closeLeaf", () => {
|
|
it("returns null when closing the only (root) leaf", () => {
|
|
const leaf = newLeaf();
|
|
expect(closeLeaf(leaf, leaf.id)).toBeNull();
|
|
});
|
|
|
|
it("collapses to the sibling when closing one of two children", () => {
|
|
const target = newLeaf();
|
|
const sibling = newLeaf();
|
|
const root = newSplit("h", target, sibling);
|
|
expect(closeLeaf(root, target.id)).toBe(sibling);
|
|
expect(closeLeaf(root, sibling.id)).toBe(target);
|
|
});
|
|
|
|
it("preserves the rest of the tree when removing a nested leaf", () => {
|
|
const target = newLeaf({ label: "close-me" });
|
|
const sib = newLeaf({ label: "stay" });
|
|
const farRight = newLeaf({ label: "right" });
|
|
const root = newSplit("h", newSplit("v", target, sib), farRight);
|
|
const next = closeLeaf(root, target.id) as SplitNode;
|
|
// The inner split collapses to `sib`; the outer split is rebuilt.
|
|
expect(next.kind).toBe("split");
|
|
expect(next.b).toBe(farRight);
|
|
expect((next.a as LeafNode).label).toBe("stay");
|
|
});
|
|
|
|
it("is a no-op when the leaf id is not found", () => {
|
|
const a = newLeaf();
|
|
const b = newLeaf();
|
|
const root = newSplit("h", a, b);
|
|
expect(closeLeaf(root, "no-such-id")).toBe(root);
|
|
});
|
|
});
|
|
|
|
describe("findLeaf", () => {
|
|
it("finds the root leaf", () => {
|
|
const leaf = newLeaf({ label: "x" });
|
|
expect(findLeaf(leaf, leaf.id)).toBe(leaf);
|
|
});
|
|
|
|
it("finds a deeply nested leaf", () => {
|
|
const target = newLeaf({ label: "deep" });
|
|
const root = newSplit(
|
|
"h",
|
|
newSplit("v", newLeaf(), newSplit("h", target, newLeaf())),
|
|
newLeaf(),
|
|
);
|
|
const found = findLeaf(root, target.id);
|
|
expect(found).toBe(target);
|
|
});
|
|
|
|
it("returns null for unknown id", () => {
|
|
const root = newSplit("h", newLeaf(), newLeaf());
|
|
expect(findLeaf(root, "no-such-id")).toBeNull();
|
|
});
|
|
|
|
it("returns null when searching for a split node's id", () => {
|
|
const root = newSplit("h", newLeaf(), newLeaf());
|
|
expect(findLeaf(root, root.id)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("leafCount", () => {
|
|
it("counts a single leaf as 1", () => {
|
|
expect(leafCount(newLeaf())).toBe(1);
|
|
});
|
|
|
|
it("counts leaves across nested splits", () => {
|
|
const tree = newSplit(
|
|
"h",
|
|
newSplit("v", newLeaf(), newLeaf()),
|
|
newSplit("h", newLeaf(), newSplit("v", newLeaf(), newLeaf())),
|
|
);
|
|
expect(leafCount(tree)).toBe(5);
|
|
});
|
|
});
|
|
|
|
describe("walkLeaves", () => {
|
|
it("yields a single leaf for a leaf root", () => {
|
|
const leaf = newLeaf();
|
|
expect(Array.from(walkLeaves(leaf))).toEqual([leaf]);
|
|
});
|
|
|
|
it("yields leaves in left-to-right (a-first) order", () => {
|
|
const l1 = newLeaf({ label: "1" });
|
|
const l2 = newLeaf({ label: "2" });
|
|
const l3 = newLeaf({ label: "3" });
|
|
const l4 = newLeaf({ label: "4" });
|
|
const root = newSplit("h", newSplit("v", l1, l2), newSplit("h", l3, l4));
|
|
const labels = Array.from(walkLeaves(root)).map((l) => l.label);
|
|
expect(labels).toEqual(["1", "2", "3", "4"]);
|
|
});
|
|
});
|
|
|
|
describe("changeDistro", () => {
|
|
it("sets the distro on the leaf and forces shellKind back to wsl", () => {
|
|
const leaf = newLeaf({ shellKind: "powershell" });
|
|
const next = changeDistro(leaf, leaf.id, "Debian") as LeafNode;
|
|
expect(next.distro).toBe("Debian");
|
|
expect(next.shellKind).toBe("wsl");
|
|
});
|
|
|
|
it("MUST swap the leaf id (so {#key} remounts XtermPane and kills the PTY)", () => {
|
|
const leaf = newLeaf({ distro: "Ubuntu" });
|
|
const next = changeDistro(leaf, leaf.id, "Debian") as LeafNode;
|
|
expect(next.id).not.toBe(leaf.id);
|
|
});
|
|
|
|
it("preserves other leaves in a nested tree", () => {
|
|
const target = newLeaf({ distro: "Ubuntu" });
|
|
const other = newLeaf({ distro: "Ubuntu" });
|
|
const root = newSplit("h", target, other);
|
|
const next = changeDistro(root, target.id, "Debian") as SplitNode;
|
|
expect(next.b).toBe(other);
|
|
expect((next.a as LeafNode).distro).toBe("Debian");
|
|
});
|
|
});
|
|
|
|
describe("setLeafShell", () => {
|
|
it("switches a wsl leaf to powershell (and clears wsl-specific fields)", () => {
|
|
const leaf = newLeaf({ distro: "Ubuntu", cwd: "/work", label: "keep" });
|
|
const next = setLeafShell(leaf, leaf.id, { shellKind: "powershell" }) as LeafNode;
|
|
expect(next.shellKind).toBe("powershell");
|
|
expect(next.distro).toBeUndefined();
|
|
expect(next.cwd).toBeUndefined();
|
|
expect(next.label).toBe("keep");
|
|
});
|
|
|
|
it("switches a leaf to ssh and records sshHostId", () => {
|
|
const leaf = newLeaf({ distro: "Ubuntu" });
|
|
const next = setLeafShell(leaf, leaf.id, {
|
|
shellKind: "ssh",
|
|
sshHostId: "host-abc",
|
|
}) as LeafNode;
|
|
expect(next.shellKind).toBe("ssh");
|
|
expect(next.sshHostId).toBe("host-abc");
|
|
expect(next.distro).toBeUndefined();
|
|
});
|
|
|
|
it("MUST swap the leaf id (forces PTY respawn)", () => {
|
|
const leaf = newLeaf({ shellKind: "powershell" });
|
|
const next = setLeafShell(leaf, leaf.id, {
|
|
shellKind: "ssh",
|
|
sshHostId: "h1",
|
|
}) as LeafNode;
|
|
expect(next.id).not.toBe(leaf.id);
|
|
});
|
|
|
|
it("preserves label / broadcast / fontSizeOffset across the shell change", () => {
|
|
const leaf = newLeaf({
|
|
distro: "Ubuntu",
|
|
label: "my pane",
|
|
broadcast: true,
|
|
fontSizeOffset: 2,
|
|
});
|
|
const next = setLeafShell(leaf, leaf.id, {
|
|
shellKind: "powershell",
|
|
}) as LeafNode;
|
|
expect(next.label).toBe("my pane");
|
|
expect(next.broadcast).toBe(true);
|
|
expect(next.fontSizeOffset).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe("changeLabel", () => {
|
|
it("sets a label", () => {
|
|
const leaf = newLeaf();
|
|
const next = changeLabel(leaf, leaf.id, "my pane") as LeafNode;
|
|
expect(next.label).toBe("my pane");
|
|
});
|
|
|
|
it("MUST NOT swap the leaf id (metadata-only — pane should not respawn)", () => {
|
|
const leaf = newLeaf({ label: "old" });
|
|
const next = changeLabel(leaf, leaf.id, "new") as LeafNode;
|
|
expect(next.id).toBe(leaf.id);
|
|
});
|
|
|
|
it("trims whitespace from the label", () => {
|
|
const leaf = newLeaf();
|
|
const next = changeLabel(leaf, leaf.id, " spaced ") as LeafNode;
|
|
expect(next.label).toBe("spaced");
|
|
});
|
|
|
|
it("clears the label when given empty / whitespace / undefined", () => {
|
|
const leaf = newLeaf({ label: "had-a-name" });
|
|
expect((changeLabel(leaf, leaf.id, "") as LeafNode).label).toBeUndefined();
|
|
expect((changeLabel(leaf, leaf.id, " ") as LeafNode).label).toBeUndefined();
|
|
expect(
|
|
(changeLabel(leaf, leaf.id, undefined) as LeafNode).label,
|
|
).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("toggleBroadcast", () => {
|
|
it("toggles undefined -> true", () => {
|
|
const leaf = newLeaf();
|
|
const next = toggleBroadcast(leaf, leaf.id) as LeafNode;
|
|
expect(next.broadcast).toBe(true);
|
|
});
|
|
|
|
it("toggles true -> false", () => {
|
|
const leaf = newLeaf({ distro: "Ubuntu" });
|
|
const on = toggleBroadcast(leaf, leaf.id) as LeafNode;
|
|
const off = toggleBroadcast(on, on.id) as LeafNode;
|
|
expect(off.broadcast).toBe(false);
|
|
});
|
|
|
|
it("MUST NOT swap the leaf id (metadata-only)", () => {
|
|
const leaf = newLeaf();
|
|
const next = toggleBroadcast(leaf, leaf.id) as LeafNode;
|
|
expect(next.id).toBe(leaf.id);
|
|
});
|
|
});
|
|
|
|
describe("toggleMcpAllow", () => {
|
|
it("default-undefined toggles to true", () => {
|
|
const leaf = newLeaf();
|
|
expect(leaf.mcpAllow).toBeUndefined();
|
|
const on = toggleMcpAllow(leaf, leaf.id) as LeafNode;
|
|
expect(on.mcpAllow).toBe(true);
|
|
});
|
|
|
|
it("true toggles to false", () => {
|
|
const leaf = newLeaf({ mcpAllow: true });
|
|
const off = toggleMcpAllow(leaf, leaf.id) as LeafNode;
|
|
expect(off.mcpAllow).toBe(false);
|
|
});
|
|
|
|
it("MUST NOT swap the leaf id (metadata-only, no PTY respawn)", () => {
|
|
const leaf = newLeaf();
|
|
const next = toggleMcpAllow(leaf, leaf.id) as LeafNode;
|
|
expect(next.id).toBe(leaf.id);
|
|
});
|
|
});
|
|
|
|
describe("resolveFontSize", () => {
|
|
it("returns the default when offset is undefined or 0", () => {
|
|
expect(resolveFontSize(undefined)).toBe(DEFAULT_FONT_SIZE);
|
|
expect(resolveFontSize(0)).toBe(DEFAULT_FONT_SIZE);
|
|
});
|
|
|
|
it("clamps to [MIN_FONT_SIZE, MAX_FONT_SIZE]", () => {
|
|
expect(resolveFontSize(-9999)).toBe(MIN_FONT_SIZE);
|
|
expect(resolveFontSize(9999)).toBe(MAX_FONT_SIZE);
|
|
});
|
|
});
|
|
|
|
describe("adjustFontSize", () => {
|
|
it("bumps a leaf's offset by delta", () => {
|
|
const leaf = newLeaf();
|
|
const next = adjustFontSize(leaf, leaf.id, 2) as LeafNode;
|
|
expect(next.fontSizeOffset).toBe(2);
|
|
});
|
|
|
|
it("MUST NOT swap the leaf id (metadata-only — pane should not respawn)", () => {
|
|
const leaf = newLeaf();
|
|
const next = adjustFontSize(leaf, leaf.id, 1) as LeafNode;
|
|
expect(next.id).toBe(leaf.id);
|
|
});
|
|
|
|
it("clamps the offset so the resolved font size stays within bounds", () => {
|
|
const leaf = newLeaf();
|
|
const bigUp = adjustFontSize(leaf, leaf.id, 999) as LeafNode;
|
|
expect(resolveFontSize(bigUp.fontSizeOffset)).toBe(MAX_FONT_SIZE);
|
|
const bigDown = adjustFontSize(leaf, leaf.id, -999) as LeafNode;
|
|
expect(resolveFontSize(bigDown.fontSizeOffset)).toBe(MIN_FONT_SIZE);
|
|
});
|
|
|
|
it("strips the offset field entirely when the result is 0", () => {
|
|
const leaf = newLeaf({ fontSizeOffset: 1 });
|
|
const next = adjustFontSize(leaf, leaf.id, -1) as LeafNode;
|
|
expect(next.fontSizeOffset).toBeUndefined();
|
|
expect("fontSizeOffset" in next).toBe(false);
|
|
});
|
|
|
|
it("delta=null resets to default", () => {
|
|
const leaf = newLeaf({ fontSizeOffset: 5 });
|
|
const next = adjustFontSize(leaf, leaf.id, null) as LeafNode;
|
|
expect(next.fontSizeOffset).toBeUndefined();
|
|
});
|
|
|
|
it("only touches the targeted leaf", () => {
|
|
const target = newLeaf({ label: "a" });
|
|
const sibling = newLeaf({ label: "b", fontSizeOffset: 3 });
|
|
const root = newSplit("h", target, sibling);
|
|
const next = adjustFontSize(root, target.id, 2) as SplitNode;
|
|
expect((next.a as LeafNode).fontSizeOffset).toBe(2);
|
|
expect((next.b as LeafNode).fontSizeOffset).toBe(3);
|
|
});
|
|
});
|
|
|
|
describe("adjustAllFontSizes", () => {
|
|
it("shifts every leaf by the same delta and preserves independence", () => {
|
|
const a = newLeaf({ fontSizeOffset: 0 });
|
|
const b = newLeaf({ fontSizeOffset: 2 });
|
|
const c = newLeaf({ fontSizeOffset: -1 });
|
|
const root = newSplit("h", a, newSplit("v", b, c));
|
|
const next = adjustAllFontSizes(root, 1);
|
|
const offsets = Array.from(walkLeaves(next)).map((l) => l.fontSizeOffset ?? 0);
|
|
expect(offsets).toEqual([1, 3, 0]);
|
|
});
|
|
|
|
it("delta=null resets every leaf to default", () => {
|
|
const a = newLeaf({ fontSizeOffset: 4 });
|
|
const b = newLeaf({ fontSizeOffset: -3 });
|
|
const root = newSplit("h", a, b);
|
|
const next = adjustAllFontSizes(root, null);
|
|
for (const leaf of walkLeaves(next)) {
|
|
expect(leaf.fontSizeOffset).toBeUndefined();
|
|
}
|
|
});
|
|
|
|
it("MUST NOT swap any leaf id", () => {
|
|
const a = newLeaf({ fontSizeOffset: 1 });
|
|
const b = newLeaf();
|
|
const root = newSplit("h", a, b);
|
|
const idsBefore = leafIds(root);
|
|
const next = adjustAllFontSizes(root, 1);
|
|
expect(leafIds(next)).toEqual(idsBefore);
|
|
});
|
|
|
|
it("returns the same root reference when nothing changes (e.g. all at min, delta < 0)", () => {
|
|
const minOffset = MIN_FONT_SIZE - DEFAULT_FONT_SIZE;
|
|
const a = newLeaf({ fontSizeOffset: minOffset });
|
|
const b = newLeaf({ fontSizeOffset: minOffset });
|
|
const root = newSplit("h", a, b);
|
|
expect(adjustAllFontSizes(root, -1)).toBe(root);
|
|
});
|
|
});
|
|
|
|
describe("presets", () => {
|
|
it("presetSingle returns a single leaf with the provided distro", () => {
|
|
const t = presetSingle({ distro: "Ubuntu" });
|
|
expect(t.kind).toBe("leaf");
|
|
expect((t as LeafNode).distro).toBe("Ubuntu");
|
|
});
|
|
|
|
it("presetTwoColumns returns a horizontal split with two leaves", () => {
|
|
const t = presetTwoColumns({ distro: "Ubuntu" }) as SplitNode;
|
|
expect(t.kind).toBe("split");
|
|
expect(t.orientation).toBe("h");
|
|
expect(leafCount(t)).toBe(2);
|
|
expect(leafDistros(t)).toEqual(["Ubuntu", "Ubuntu"]);
|
|
});
|
|
|
|
it("presetThreeColumns has 3 leaves with the outer split at 1/3", () => {
|
|
const t = presetThreeColumns({ distro: "Ubuntu" }) as SplitNode;
|
|
expect(leafCount(t)).toBe(3);
|
|
expect(t.orientation).toBe("h");
|
|
expect(t.ratio).toBeCloseTo(1 / 3);
|
|
});
|
|
|
|
it("presetTwoRows returns a vertical split", () => {
|
|
const t = presetTwoRows() as SplitNode;
|
|
expect(t.orientation).toBe("v");
|
|
expect(leafCount(t)).toBe(2);
|
|
});
|
|
|
|
it("presetTwoByTwo returns 4 leaves in a v(h, h) shape, distro propagated", () => {
|
|
const t = presetTwoByTwo({ distro: "Ubuntu" }) as SplitNode;
|
|
expect(leafCount(t)).toBe(4);
|
|
expect(t.orientation).toBe("v");
|
|
expect((t.a as SplitNode).orientation).toBe("h");
|
|
expect((t.b as SplitNode).orientation).toBe("h");
|
|
expect(leafDistros(t)).toEqual(["Ubuntu", "Ubuntu", "Ubuntu", "Ubuntu"]);
|
|
});
|
|
|
|
it("each preset call yields fresh ids (no collisions across applications)", () => {
|
|
const a = presetTwoByTwo();
|
|
const b = presetTwoByTwo();
|
|
const intersection = leafIds(a).filter((id) => leafIds(b).includes(id));
|
|
expect(intersection).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("promoteLeaf", () => {
|
|
it("HSplit(a, VSplit(b, c)) + promote c → VSplit(HSplit(a, b), c)", () => {
|
|
const a = newLeaf({ label: "a" });
|
|
const b = newLeaf({ label: "b" });
|
|
const c = newLeaf({ label: "c" });
|
|
const tree = newSplit("h", a, newSplit("v", b, c, 0.5), 0.5);
|
|
const next = promoteLeaf(tree, c.id) as SplitNode;
|
|
expect(next.orientation).toBe("v");
|
|
const top = next.a as SplitNode;
|
|
expect(top.orientation).toBe("h");
|
|
expect((top.a as LeafNode).label).toBe("a");
|
|
expect((top.b as LeafNode).label).toBe("b");
|
|
expect((next.b as LeafNode).label).toBe("c");
|
|
});
|
|
|
|
it("HSplit(a, VSplit(b, c)) + promote b → VSplit(b, HSplit(a, c))", () => {
|
|
const a = newLeaf({ label: "a" });
|
|
const b = newLeaf({ label: "b" });
|
|
const c = newLeaf({ label: "c" });
|
|
const tree = newSplit("h", a, newSplit("v", b, c, 0.5), 0.5);
|
|
const next = promoteLeaf(tree, b.id) as SplitNode;
|
|
expect(next.orientation).toBe("v");
|
|
expect((next.a as LeafNode).label).toBe("b");
|
|
const bot = next.b as SplitNode;
|
|
expect(bot.orientation).toBe("h");
|
|
expect((bot.a as LeafNode).label).toBe("a");
|
|
expect((bot.b as LeafNode).label).toBe("c");
|
|
});
|
|
|
|
it("is self-inverse — promote c then promote a returns the original shape", () => {
|
|
const a = newLeaf({ label: "a" });
|
|
const b = newLeaf({ label: "b" });
|
|
const c = newLeaf({ label: "c" });
|
|
const tree = newSplit("h", a, newSplit("v", b, c, 0.5), 0.5);
|
|
const promoted = promoteLeaf(tree, c.id)!;
|
|
const restored = promoteLeaf(promoted, a.id) as SplitNode;
|
|
expect(restored.orientation).toBe("h");
|
|
expect((restored.a as LeafNode).label).toBe("a");
|
|
const inner = restored.b as SplitNode;
|
|
expect(inner.orientation).toBe("v");
|
|
expect((inner.a as LeafNode).label).toBe("b");
|
|
expect((inner.b as LeafNode).label).toBe("c");
|
|
});
|
|
|
|
it("returns null when the leaf has no parent (single-leaf root)", () => {
|
|
const leaf = newLeaf();
|
|
expect(promoteLeaf(leaf, leaf.id)).toBeNull();
|
|
});
|
|
|
|
it("returns null when the leaf's parent is the root (no grandparent)", () => {
|
|
const a = newLeaf();
|
|
const b = newLeaf();
|
|
const root = newSplit("h", a, b);
|
|
expect(promoteLeaf(root, a.id)).toBeNull();
|
|
});
|
|
|
|
it("returns null when parent and grandparent share orientation", () => {
|
|
const a = newLeaf();
|
|
const b = newLeaf();
|
|
const c = newLeaf();
|
|
const inner = newSplit("h", b, c);
|
|
const root = newSplit("h", a, inner);
|
|
expect(promoteLeaf(root, b.id)).toBeNull();
|
|
});
|
|
|
|
it("preserves all leaf ids (no PTYs respawn on promote)", () => {
|
|
const a = newLeaf({ label: "a" });
|
|
const b = newLeaf({ label: "b" });
|
|
const c = newLeaf({ label: "c" });
|
|
const tree = newSplit("h", a, newSplit("v", b, c));
|
|
const before = Array.from(walkLeaves(tree))
|
|
.map((l) => l.id)
|
|
.sort();
|
|
const after = Array.from(walkLeaves(promoteLeaf(tree, c.id)!))
|
|
.map((l) => l.id)
|
|
.sort();
|
|
expect(after).toEqual(before);
|
|
});
|
|
});
|
|
|
|
describe("serialize / deserialize", () => {
|
|
it("roundtrips a complex tree", () => {
|
|
const leaf1 = newLeaf({ distro: "Ubuntu", label: "left", broadcast: true });
|
|
const leaf2 = newLeaf({ distro: "Debian", cwd: "/projects/y" });
|
|
const leaf3 = newLeaf();
|
|
const tree = newSplit("h", leaf1, newSplit("v", leaf2, leaf3, 0.7), 0.4);
|
|
const back = deserialize(serialize(tree));
|
|
expect(back).toEqual(tree);
|
|
});
|
|
|
|
it("returns null on syntactically invalid JSON", () => {
|
|
expect(deserialize("not json")).toBeNull();
|
|
});
|
|
|
|
it("returns null on JSON that doesn't match the tree shape", () => {
|
|
expect(deserialize('{"not": "a tree"}')).toBeNull();
|
|
expect(deserialize('{"kind": "leaf"}')).toBeNull(); // missing id
|
|
expect(
|
|
deserialize('{"kind": "split", "id": "x", "orientation": "h"}'),
|
|
).toBeNull(); // missing ratio + children
|
|
});
|
|
|
|
it("accepts a minimal leaf shape (backfilling shellKind for legacy data)", () => {
|
|
expect(deserialize('{"kind": "leaf", "id": "x"}')).toEqual({
|
|
kind: "leaf",
|
|
id: "x",
|
|
shellKind: "wsl",
|
|
});
|
|
});
|
|
|
|
it("migrates legacy PowerShell-sentinel leaves to shellKind=powershell", () => {
|
|
const legacy = JSON.stringify({
|
|
kind: "split",
|
|
id: "s1",
|
|
orientation: "h",
|
|
ratio: 0.5,
|
|
a: { kind: "leaf", id: "a", distro: "PowerShell" },
|
|
b: { kind: "leaf", id: "b", distro: "Ubuntu" },
|
|
});
|
|
const back = deserialize(legacy) as SplitNode;
|
|
const left = back.a as LeafNode;
|
|
const right = back.b as LeafNode;
|
|
expect(left.shellKind).toBe("powershell");
|
|
expect(left.distro).toBeUndefined();
|
|
expect(right.shellKind).toBe("wsl");
|
|
expect(right.distro).toBe("Ubuntu");
|
|
});
|
|
|
|
it("leaves shellKind alone on already-migrated leaves", () => {
|
|
const fresh = JSON.stringify({
|
|
kind: "leaf",
|
|
id: "x",
|
|
shellKind: "ssh",
|
|
sshHostId: "h-1",
|
|
});
|
|
const back = deserialize(fresh) as LeafNode;
|
|
expect(back.shellKind).toBe("ssh");
|
|
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");
|
|
});
|
|
});
|