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
|
|
@ -159,6 +159,61 @@
|
|||
color: #cce6ff;
|
||||
}
|
||||
|
||||
.shell-menu {
|
||||
min-width: 200px;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.shell-menu-header {
|
||||
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
|
||||
font-size: 9px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: #666;
|
||||
padding: 6px 8px 2px 8px;
|
||||
margin-top: 2px;
|
||||
border-top: 1px solid #2a2a2a;
|
||||
}
|
||||
.shell-menu-header:first-child {
|
||||
border-top: none;
|
||||
margin-top: 0;
|
||||
}
|
||||
.shell-menu-empty {
|
||||
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
|
||||
font-size: 10px;
|
||||
color: #555;
|
||||
padding: 3px 8px;
|
||||
font-style: italic;
|
||||
}
|
||||
.distro-menu-item.shell-menu-manage {
|
||||
margin-top: 4px;
|
||||
border-top: 1px solid #2a2a2a;
|
||||
padding-top: 6px;
|
||||
color: #88c;
|
||||
}
|
||||
|
||||
.leaf-missing-host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
padding: 16px;
|
||||
background: #0c0c0c;
|
||||
color: #d66;
|
||||
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
.leaf-missing-host p {
|
||||
margin: 4px 0;
|
||||
}
|
||||
.leaf-missing-host .hint {
|
||||
color: #888;
|
||||
font-size: 11px;
|
||||
max-width: 36ch;
|
||||
}
|
||||
|
||||
.pane-status {
|
||||
margin-left: auto;
|
||||
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
|
||||
|
|
|
|||
|
|
@ -7,9 +7,10 @@ import {
|
|||
type MouseEvent,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
} from "react";
|
||||
import { type LeafNode, resolveFontSize } from "./tree";
|
||||
import { type LeafNode, resolveFontSize, type LeafShellSpec } from "./tree";
|
||||
import { useOrchestration } from "./orchestration";
|
||||
import XtermPane from "../../components/XtermPane";
|
||||
import type { SpawnSpec } from "../../ipc";
|
||||
import "./LeafPane.css";
|
||||
|
||||
const IDLE_THRESHOLD_MS = 5000;
|
||||
|
|
@ -57,26 +58,60 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
|
|||
[commitLabel, cancelLabel],
|
||||
);
|
||||
|
||||
// ---- distro popover ----------------------------------------------------
|
||||
const [distroOpen, setDistroOpen] = useState(false);
|
||||
const toggleDistroMenu = useCallback((e: MouseEvent) => {
|
||||
// ---- shell-picker popover ----------------------------------------------
|
||||
// Hierarchical menu: WSL distros, then Windows (PowerShell), then SSH
|
||||
// hosts + a "Manage hosts…" entry. Picking any item swaps the leaf id
|
||||
// (forces respawn).
|
||||
const [shellMenuOpen, setShellMenuOpen] = useState(false);
|
||||
const toggleShellMenu = useCallback((e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setDistroOpen((v) => !v);
|
||||
setShellMenuOpen((v) => !v);
|
||||
}, []);
|
||||
const pickDistro = useCallback(
|
||||
(d: string) => {
|
||||
setDistroOpen(false);
|
||||
if (d !== leaf.distro) orch.setDistro(leaf.id, d);
|
||||
const pickShell = useCallback(
|
||||
(spec: LeafShellSpec) => {
|
||||
setShellMenuOpen(false);
|
||||
// Only respawn if the spec is actually different from what's running.
|
||||
if (spec.shellKind === "wsl" && leaf.shellKind === "wsl" && spec.distro === leaf.distro) {
|
||||
return;
|
||||
}
|
||||
if (spec.shellKind === "powershell" && leaf.shellKind === "powershell") {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
spec.shellKind === "ssh" &&
|
||||
leaf.shellKind === "ssh" &&
|
||||
spec.sshHostId === leaf.sshHostId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
orch.setShell(leaf.id, spec);
|
||||
},
|
||||
[orch.setDistro, leaf.id, leaf.distro],
|
||||
[orch.setShell, leaf.id, leaf.shellKind, leaf.distro, leaf.sshHostId],
|
||||
);
|
||||
const onManageHosts = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setShellMenuOpen(false);
|
||||
orch.openHostManager();
|
||||
},
|
||||
[orch.openHostManager],
|
||||
);
|
||||
// Dismiss popover on outside click
|
||||
useEffect(() => {
|
||||
if (!distroOpen) return;
|
||||
const onDocClick = () => setDistroOpen(false);
|
||||
if (!shellMenuOpen) return;
|
||||
const onDocClick = () => setShellMenuOpen(false);
|
||||
window.addEventListener("click", onDocClick);
|
||||
return () => window.removeEventListener("click", onDocClick);
|
||||
}, [distroOpen]);
|
||||
}, [shellMenuOpen]);
|
||||
|
||||
// Label shown on the dropdown chip — tells the user what's currently
|
||||
// running without expanding the menu.
|
||||
const chipLabel =
|
||||
leaf.shellKind === "powershell"
|
||||
? "PowerShell"
|
||||
: leaf.shellKind === "ssh"
|
||||
? `ssh: ${orch.hosts.find((h) => h.id === leaf.sshHostId)?.label ?? "(missing host)"}`
|
||||
: (leaf.distro ?? "(default)");
|
||||
|
||||
// ---- idle detection ----------------------------------------------------
|
||||
// Local boolean for the red border + status text on this pane; reported
|
||||
|
|
@ -233,6 +268,29 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
|
|||
|
||||
const labelText = leaf.label ?? "(unnamed)";
|
||||
|
||||
// Resolve the SpawnSpec from the leaf + host table. If shellKind=ssh but
|
||||
// the referenced host was deleted, we surface an error in the toolbar
|
||||
// status instead of spawning an unrelated shell.
|
||||
const spec: SpawnSpec | null = (() => {
|
||||
if (leaf.shellKind === "wsl") {
|
||||
return { kind: "wsl", distro: leaf.distro, cwd: leaf.cwd };
|
||||
}
|
||||
if (leaf.shellKind === "powershell") {
|
||||
return { kind: "powershell" };
|
||||
}
|
||||
const host = orch.hosts.find((h) => h.id === leaf.sshHostId);
|
||||
if (!host) return null;
|
||||
return {
|
||||
kind: "ssh",
|
||||
host: host.hostname,
|
||||
user: host.user,
|
||||
port: host.port,
|
||||
identityFile: host.identityFile,
|
||||
jumpHost: host.jumpHost,
|
||||
extraArgs: host.extraArgs,
|
||||
};
|
||||
})();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`leaf${isActive ? " active" : ""}${isBroadcasting ? " broadcasting" : ""}${isIdle ? " idle" : ""}${isDragSource ? " drag-source" : ""}${isDragTarget ? " drag-target" : ""}`}
|
||||
|
|
@ -271,26 +329,74 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
|
|||
<span className="distro-wrap">
|
||||
<button
|
||||
className="distro-chip"
|
||||
onClick={toggleDistroMenu}
|
||||
title="Change distro (respawns the pane)"
|
||||
onClick={toggleShellMenu}
|
||||
title="Change shell (respawns the pane)"
|
||||
>
|
||||
{leaf.distro ?? "(default)"} ▾
|
||||
{chipLabel} ▾
|
||||
</button>
|
||||
{distroOpen && (
|
||||
{shellMenuOpen && (
|
||||
<div
|
||||
className="distro-menu"
|
||||
className="distro-menu shell-menu"
|
||||
role="menu"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{orch.distros.map((d) => (
|
||||
<button
|
||||
key={d}
|
||||
className={`distro-menu-item${d === leaf.distro ? " active" : ""}`}
|
||||
onClick={() => pickDistro(d)}
|
||||
>
|
||||
{d}
|
||||
</button>
|
||||
))}
|
||||
{orch.distros.length > 0 && (
|
||||
<>
|
||||
<div className="shell-menu-header">WSL</div>
|
||||
{orch.distros.map((d) => {
|
||||
const active = leaf.shellKind === "wsl" && d === leaf.distro;
|
||||
return (
|
||||
<button
|
||||
key={`wsl-${d}`}
|
||||
className={`distro-menu-item${active ? " active" : ""}`}
|
||||
onClick={() => pickShell({ shellKind: "wsl", distro: d })}
|
||||
>
|
||||
{d}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="shell-menu-header">Windows</div>
|
||||
<button
|
||||
className={`distro-menu-item${leaf.shellKind === "powershell" ? " active" : ""}`}
|
||||
onClick={() => pickShell({ shellKind: "powershell" })}
|
||||
>
|
||||
PowerShell
|
||||
</button>
|
||||
|
||||
<div className="shell-menu-header">SSH</div>
|
||||
{orch.hosts.length === 0 ? (
|
||||
<div className="shell-menu-empty">(no saved hosts)</div>
|
||||
) : (
|
||||
orch.hosts.map((h) => {
|
||||
const active =
|
||||
leaf.shellKind === "ssh" && h.id === leaf.sshHostId;
|
||||
return (
|
||||
<button
|
||||
key={`ssh-${h.id}`}
|
||||
className={`distro-menu-item${active ? " active" : ""}`}
|
||||
onClick={() =>
|
||||
pickShell({ shellKind: "ssh", sshHostId: h.id })
|
||||
}
|
||||
title={
|
||||
h.user
|
||||
? `${h.user}@${h.hostname}${h.port ? ":" + h.port : ""}`
|
||||
: `${h.hostname}${h.port ? ":" + h.port : ""}`
|
||||
}
|
||||
>
|
||||
{h.label || h.hostname}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
<button
|
||||
className="distro-menu-item shell-menu-manage"
|
||||
onClick={onManageHosts}
|
||||
>
|
||||
Manage hosts…
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
|
|
@ -356,17 +462,26 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
|
|||
</span>
|
||||
</div>
|
||||
<div className="xterm-wrap">
|
||||
<XtermPane
|
||||
distro={leaf.distro}
|
||||
cwd={leaf.cwd}
|
||||
onStatus={onStatus}
|
||||
onSpawn={onPaneSpawned}
|
||||
onInput={onTerminalInput}
|
||||
onDataReceived={onDataReceived}
|
||||
onFocus={onXtermFocus}
|
||||
focusTrigger={focusTrigger}
|
||||
fontSize={resolveFontSize(leaf.fontSizeOffset)}
|
||||
/>
|
||||
{spec ? (
|
||||
<XtermPane
|
||||
spec={spec}
|
||||
onStatus={onStatus}
|
||||
onSpawn={onPaneSpawned}
|
||||
onInput={onTerminalInput}
|
||||
onDataReceived={onDataReceived}
|
||||
onFocus={onXtermFocus}
|
||||
focusTrigger={focusTrigger}
|
||||
fontSize={resolveFontSize(leaf.fontSizeOffset)}
|
||||
/>
|
||||
) : (
|
||||
<div className="leaf-missing-host">
|
||||
<p>SSH host not found</p>
|
||||
<p className="hint">
|
||||
Open the shell menu and pick another host, or add this host back
|
||||
via Manage hosts….
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { createContext, useContext, type ReactNode } from "react";
|
||||
import type { Orientation, NodeId } from "./tree";
|
||||
import type { PaneId } from "../../ipc";
|
||||
import type { Orientation, NodeId, LeafShellSpec } from "./tree";
|
||||
import type { PaneId, SshHost } from "../../ipc";
|
||||
|
||||
/**
|
||||
* Orchestration context — every piece of shared state and every operation
|
||||
|
|
@ -15,15 +15,26 @@ import type { PaneId } from "../../ipc";
|
|||
export interface Orchestration {
|
||||
// Read-only state
|
||||
activeLeafId: NodeId | null;
|
||||
/** WSL distros enumerated from `wsl.exe -l -q`. PowerShell is a separate
|
||||
* shell kind, not in this list. */
|
||||
distros: string[];
|
||||
/** Saved SSH hosts loaded from `hosts.json`. Reactive — changes when the
|
||||
* user edits hosts via {@link openHostManager}. */
|
||||
hosts: SshHost[];
|
||||
|
||||
// Tree mutations
|
||||
split: (leafId: NodeId, orientation: Orientation) => void;
|
||||
close: (leafId: NodeId) => void;
|
||||
setDistro: (leafId: NodeId, distro: string) => void;
|
||||
/** Change the shell on a leaf (WSL distro / PowerShell / SSH host).
|
||||
* Always forces a respawn — the helper in tree.ts swaps the leaf id so
|
||||
* the renderer remounts XtermPane. */
|
||||
setShell: (leafId: NodeId, spec: LeafShellSpec) => void;
|
||||
setLabel: (leafId: NodeId, label: string | undefined) => void;
|
||||
toggleBroadcast: (leafId: NodeId) => void;
|
||||
|
||||
// SSH host management
|
||||
openHostManager: () => void;
|
||||
|
||||
// Per-pane orchestration
|
||||
setActive: (leafId: NodeId) => void;
|
||||
registerPaneId: (leafId: NodeId, paneId: PaneId | null) => void;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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