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

@ -10,13 +10,25 @@ export type NodeId = string;
/** 'h' = side-by-side (a on left, b on right). 'v' = stacked (a on top, b below). */
export type Orientation = "h" | "v";
/** What kind of shell a leaf is running. Determines which fields on
* LeafNode are meaningful at spawn time and which spawn-spec the backend
* receives. Migration on deserialize backfills this for pre-shellKind
* workspaces (PowerShell was previously a sentinel `distro` string). */
export type ShellKind = "wsl" | "powershell" | "ssh";
export interface LeafNode {
kind: "leaf";
id: NodeId;
/** WSL distro the pane was spawned against. */
/** Discriminator: which shell-type this pane runs. */
shellKind: ShellKind;
/** WSL distro the pane was spawned against. Only meaningful when
* shellKind === "wsl". */
distro?: string;
/** Working directory the pane was started in. Not currently used at spawn time but preserved for future. */
/** Working directory the pane was started in. Only meaningful when
* shellKind === "wsl". */
cwd?: string;
/** Saved-host id (see SshHost). Only meaningful when shellKind === "ssh". */
sshHostId?: string;
/** Optional user label shown in the pane toolbar. */
label?: string;
/**
@ -60,7 +72,47 @@ function newId(): NodeId {
}
export function newLeaf(props: Partial<Omit<LeafNode, "kind" | "id">> = {}): LeafNode {
return { kind: "leaf", id: newId(), ...props };
return { kind: "leaf", id: newId(), shellKind: "wsl", ...props };
}
/** Spec for switching a leaf's shell. Discriminated by shellKind. Used by
* {@link setLeafShell}; the helper always swaps the leaf id so the renderer
* remounts XtermPane (kills the old PTY spawns a fresh one with the new
* spec). */
export type LeafShellSpec =
| { shellKind: "wsl"; distro?: string; cwd?: string }
| { shellKind: "powershell" }
| { shellKind: "ssh"; sshHostId: string };
/**
* Replace the leaf's shell-kind and shell-specific fields, then swap its id
* so the renderer's `key={leaf.id}` block remounts XtermPane (kills the old
* PTY spawns a fresh one). Metadata like label / broadcast / font-size
* survives.
*/
export function setLeafShell(
root: TreeNode,
leafId: NodeId,
spec: LeafShellSpec,
): TreeNode {
return replaceById(root, leafId, (node) => {
if (node.kind !== "leaf") return node;
const base: LeafNode = {
kind: "leaf",
id: newId(),
shellKind: spec.shellKind,
label: node.label,
broadcast: node.broadcast,
fontSizeOffset: node.fontSizeOffset,
};
if (spec.shellKind === "wsl") {
if (spec.distro !== undefined) base.distro = spec.distro;
if (spec.cwd !== undefined) base.cwd = spec.cwd;
} else if (spec.shellKind === "ssh") {
base.sshHostId = spec.sshHostId;
}
return base;
});
}
export function newSplit(
@ -128,19 +180,18 @@ export function findLeaf(root: TreeNode, leafId: NodeId): LeafNode | null {
}
/**
* Swap the distro on a leaf. The leaf gets a **new id** so the rendering
* layer's `{#key node.id}` block remounts XtermPane the old PTY is killed
* and a fresh one spawns with the new distro.
* Swap the WSL distro on a leaf. The leaf gets a **new id** so the rendering
* layer remounts XtermPane the old PTY is killed and a fresh one spawns
* against the new distro. Also forces shellKind back to "wsl" if the leaf
* had been a non-WSL kind (which is what the existing per-pane dropdown
* does when the user picks a WSL distro entry).
*/
export function changeDistro(
root: TreeNode,
leafId: NodeId,
distro: string,
): TreeNode {
return replaceById(root, leafId, (node) => {
if (node.kind !== "leaf") return node;
return { ...node, id: newId(), distro };
});
return setLeafShell(root, leafId, { shellKind: "wsl", distro });
}
/** Set or clear a leaf's label. Does NOT remount (label is metadata only). */
@ -293,8 +344,10 @@ export function reshapeToPreset(
if (!src) break;
const slot = slots[i];
slot.id = src.id;
slot.shellKind = src.shellKind;
if (src.distro !== undefined) slot.distro = src.distro;
if (src.cwd !== undefined) slot.cwd = src.cwd;
if (src.sshHostId !== undefined) slot.sshHostId = src.sshHostId;
if (src.label !== undefined) slot.label = src.label;
if (src.broadcast !== undefined) slot.broadcast = src.broadcast;
if (src.fontSizeOffset !== undefined) slot.fontSizeOffset = src.fontSizeOffset;
@ -518,17 +571,38 @@ export function serialize(root: TreeNode): string {
return JSON.stringify(root);
}
/** Parse JSON back to a tree. Returns null on invalid input. */
/** Parse JSON back to a tree. Returns null on invalid input. Pre-shellKind
* workspaces are migrated in place: leaves without `shellKind` get one
* inferred from the legacy `distro` sentinel (`"PowerShell"` powershell,
* anything else wsl). */
export function deserialize(json: string): TreeNode | null {
try {
const parsed = JSON.parse(json);
if (!isTreeNode(parsed)) return null;
return parsed;
return migrateLegacyLeaves(parsed);
} catch {
return null;
}
}
/** Sentinel used in pre-shellKind workspaces to mark PowerShell panes. */
const LEGACY_POWERSHELL_DISTRO = "PowerShell";
function migrateLegacyLeaves(node: TreeNode): TreeNode {
if (node.kind === "leaf") {
if (node.shellKind) return node;
if (node.distro === LEGACY_POWERSHELL_DISTRO) {
const { distro: _distro, ...rest } = node;
return { ...rest, shellKind: "powershell" };
}
return { ...node, shellKind: "wsl" };
}
const a = migrateLegacyLeaves(node.a);
const b = migrateLegacyLeaves(node.b);
if (a === node.a && b === node.b) return node;
return { ...node, a, b };
}
function isTreeNode(x: unknown): x is TreeNode {
if (typeof x !== "object" || x === null) return false;
const o = x as Record<string, unknown>;