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

@ -0,0 +1,301 @@
import {
useState,
useCallback,
useEffect,
useRef,
type FormEvent,
} from "react";
import type { SshHost } from "../ipc";
import "./HostManager.css";
function newId(): string {
return (
globalThis.crypto?.randomUUID?.() ??
Math.random().toString(36).slice(2, 12)
);
}
function blankHost(): SshHost {
return { id: newId(), label: "", hostname: "" };
}
interface HostManagerProps {
hosts: SshHost[];
/** Called when the user clicks Save on a row. Returns a fresh list (with
* the edit applied) to persist. The parent owns the canonical state. */
onSave: (hosts: SshHost[]) => void;
onClose: () => void;
}
export default function HostManager({
hosts,
onSave,
onClose,
}: HostManagerProps) {
// Local editable copy. Any save / delete acts on this and pushes the
// whole list back up via onSave.
const [draft, setDraft] = useState<SshHost[]>(() => hosts.map((h) => ({ ...h })));
// Which row is being edited. null = list view only.
const [editingId, setEditingId] = useState<string | null>(null);
const dialogRef = useRef<HTMLDivElement>(null);
// Escape closes; click outside the panel closes.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
const startEdit = useCallback((id: string) => setEditingId(id), []);
const cancelEdit = useCallback(() => {
// Revert any unsaved edits to that row from props.
setDraft((cur) =>
cur.map((h) => {
if (h.id !== editingId) return h;
const original = hosts.find((o) => o.id === editingId);
// Newly-added row that was never saved? Drop it entirely on cancel.
return original ?? h;
}).filter((h) => {
if (h.id !== editingId) return true;
return hosts.some((o) => o.id === editingId);
}),
);
setEditingId(null);
}, [editingId, hosts]);
const onFieldChange = useCallback(
(id: string, field: keyof SshHost, value: string) => {
setDraft((cur) =>
cur.map((h) => {
if (h.id !== id) return h;
if (field === "port") {
if (value.trim() === "") return { ...h, port: undefined };
const n = Number(value);
if (!Number.isFinite(n) || n < 1 || n > 65535) return h;
return { ...h, port: n };
}
if (field === "extraArgs") {
const parts = value
.split(/\s+/)
.map((s) => s.trim())
.filter((s) => s.length > 0);
return { ...h, extraArgs: parts.length > 0 ? parts : undefined };
}
if (value.trim() === "" && field !== "label" && field !== "hostname") {
const next = { ...h };
delete next[field];
return next;
}
return { ...h, [field]: value };
}),
);
},
[],
);
const saveRow = useCallback(
(id: string, e: FormEvent) => {
e.preventDefault();
const row = draft.find((h) => h.id === id);
if (!row) return;
if (!row.hostname.trim()) {
// Hostname is the only truly required field. Refuse the save instead
// of silently persisting a useless entry.
return;
}
// Auto-fill label from hostname if the user left it blank.
const cleaned: SshHost = {
...row,
label: row.label.trim() || row.hostname.trim(),
hostname: row.hostname.trim(),
};
const next = draft.map((h) => (h.id === id ? cleaned : h));
setDraft(next);
onSave(next);
setEditingId(null);
},
[draft, onSave],
);
const removeRow = useCallback(
(id: string) => {
const next = draft.filter((h) => h.id !== id);
setDraft(next);
onSave(next);
if (editingId === id) setEditingId(null);
},
[draft, editingId, onSave],
);
const addRow = useCallback(() => {
const fresh = blankHost();
setDraft((cur) => [...cur, fresh]);
setEditingId(fresh.id);
}, []);
return (
<div className="host-mgr-overlay" onClick={onClose}>
<div
className="host-mgr-panel"
ref={dialogRef}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-label="Manage SSH hosts"
>
<header className="host-mgr-header">
<span className="host-mgr-title">SSH hosts</span>
<button className="host-mgr-close" onClick={onClose} aria-label="Close">
×
</button>
</header>
<div className="host-mgr-body">
{draft.length === 0 ? (
<p className="host-mgr-empty">
No saved hosts. Click <strong>Add host</strong> to create one.
</p>
) : (
<ul className="host-mgr-list">
{draft.map((h) => (
<li key={h.id} className="host-row">
{editingId === h.id ? (
<form className="host-form" onSubmit={(e) => saveRow(h.id, e)}>
<label>
Label
<input
type="text"
value={h.label}
onChange={(e) =>
onFieldChange(h.id, "label", e.target.value)
}
placeholder="prod-web"
autoFocus
/>
</label>
<label>
Hostname <span className="required">*</span>
<input
type="text"
required
value={h.hostname}
onChange={(e) =>
onFieldChange(h.id, "hostname", e.target.value)
}
placeholder="example.com or 10.0.0.5"
/>
</label>
<div className="host-form-row">
<label>
User
<input
type="text"
value={h.user ?? ""}
onChange={(e) =>
onFieldChange(h.id, "user", e.target.value)
}
placeholder="(default)"
/>
</label>
<label className="host-form-port">
Port
<input
type="number"
min={1}
max={65535}
value={h.port ?? ""}
onChange={(e) =>
onFieldChange(h.id, "port", e.target.value)
}
placeholder="22"
/>
</label>
</div>
<label>
Identity file
<input
type="text"
value={h.identityFile ?? ""}
onChange={(e) =>
onFieldChange(h.id, "identityFile", e.target.value)
}
placeholder="(uses ssh-agent / default)"
/>
</label>
<label>
Jump host
<input
type="text"
value={h.jumpHost ?? ""}
onChange={(e) =>
onFieldChange(h.id, "jumpHost", e.target.value)
}
placeholder="user@bastion[:port]"
/>
</label>
<label>
Extra ssh args
<input
type="text"
value={(h.extraArgs ?? []).join(" ")}
onChange={(e) =>
onFieldChange(h.id, "extraArgs", e.target.value)
}
placeholder="-o ServerAliveInterval=30"
/>
</label>
<div className="host-form-actions">
<button type="submit" className="primary">
Save
</button>
<button
type="button"
onClick={cancelEdit}
>
Cancel
</button>
<button
type="button"
className="danger"
onClick={() => removeRow(h.id)}
>
Delete
</button>
</div>
</form>
) : (
<div className="host-display">
<div className="host-summary">
<div className="host-summary-label">
{h.label || h.hostname}
</div>
<div className="host-summary-detail">
{h.user ? `${h.user}@` : ""}
{h.hostname}
{h.port ? `:${h.port}` : ""}
{h.jumpHost ? ` via ${h.jumpHost}` : ""}
</div>
</div>
<button
className="host-edit-btn"
onClick={() => startEdit(h.id)}
>
Edit
</button>
</div>
)}
</li>
))}
</ul>
)}
<button className="host-add-btn" onClick={addRow}>
+ Add host
</button>
</div>
</div>
</div>
);
}