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
301
src/components/HostManager.tsx
Normal file
301
src/components/HostManager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue