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
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue