Add SSH connections: saved hosts manager and hierarchical shell picker

This commit is contained in:
megaproxy 2026-05-25 19:47:37 +01:00
parent 4e5bc7e081
commit 872fb0e80e
14 changed files with 1324 additions and 171 deletions

View file

@ -9,6 +9,7 @@ import {
leafCount,
walkLeaves,
changeDistro,
setLeafShell,
changeLabel,
toggleBroadcast,
adjustFontSize,
@ -38,14 +39,16 @@ function leafDistros(root: TreeNode): (string | undefined)[] {
}
describe("newLeaf", () => {
it("returns a leaf with a unique id and no extra metadata", () => {
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();
});
@ -56,6 +59,14 @@ describe("newLeaf", () => {
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", () => {
@ -232,10 +243,11 @@ describe("walkLeaves", () => {
});
describe("changeDistro", () => {
it("sets the distro on the leaf", () => {
const leaf = newLeaf({ distro: "Ubuntu" });
const next = changeDistro(leaf, leaf.id, "Debian");
expect((next as LeafNode).distro).toBe("Debian");
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)", () => {
@ -254,6 +266,52 @@ describe("changeDistro", () => {
});
});
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();
@ -466,10 +524,41 @@ describe("serialize / deserialize", () => {
).toBeNull(); // missing ratio + children
});
it("accepts a minimal leaf shape", () => {
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");
});
});