Add SSH connections: saved hosts manager and hierarchical shell picker
This commit is contained in:
parent
4e5bc7e081
commit
872fb0e80e
14 changed files with 1324 additions and 171 deletions
|
|
@ -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>;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue