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: "" }; } /** Per-edit transient state for the password field. The actual password * text never lives on `SshHost` — it stays in this map until the user * clicks Save, at which point we either send a set/delete to keyring * via the parent callbacks or do nothing. */ interface PasswordDraft { /** What the user typed (or "" if untouched). */ input: string; /** True iff the user clicked "Remove password" — overrides `input`. */ cleared: boolean; } interface HostManagerProps { hosts: SshHost[]; /** Persist the host list (label/hostname/etc — no password). */ onSave: (hosts: SshHost[]) => void; /** Write a new password to keyring for the given host id. Called only * on Save, only when the user typed something into the password field. */ onSavePassword: (hostId: string, password: string) => void; /** Delete the keyring entry for this host id. Called when the user * clicked "Remove password" before Save. */ onClearPassword: (hostId: string) => void; onClose: () => void; } export default function HostManager({ hosts, onSave, onSavePassword, onClearPassword, onClose, }: HostManagerProps) { // Local editable copy. Any save / delete acts on this and pushes the // whole list back up via onSave. const [draft, setDraft] = useState(() => hosts.map((h) => ({ ...h }))); // Per-row password edits (keyed by host id). Absent = unchanged. const [pwDrafts, setPwDrafts] = useState>({}); // Which row is being edited. null = list view only. const [editingId, setEditingId] = useState(null); const dialogRef = useRef(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); setPwDrafts((cur) => { if (cur[id]) return cur; return { ...cur, [id]: { input: "", cleared: false } }; }); }, []); const cancelEdit = useCallback(() => { // Revert any unsaved edits to that row from props; drop password drafts. setDraft((cur) => cur .map((h) => { if (h.id !== editingId) return h; const original = hosts.find((o) => o.id === editingId); return original ?? h; }) .filter((h) => { if (h.id !== editingId) return true; return hosts.some((o) => o.id === editingId); }), ); if (editingId) { setPwDrafts((cur) => { if (!(editingId in cur)) return cur; const next = { ...cur }; delete next[editingId]; return next; }); } 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 onPasswordInput = useCallback((id: string, value: string) => { setPwDrafts((cur) => ({ ...cur, [id]: { input: value, cleared: false }, })); }, []); const onPasswordClear = useCallback((id: string) => { setPwDrafts((cur) => ({ ...cur, [id]: { input: "", cleared: true }, })); }, []); 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(), }; // Apply the password edit — if any — BEFORE flipping `hasPassword` // on the local copy so the row redraws with the right state. const pw = pwDrafts[id]; let nextHasPassword = row.hasPassword; if (pw) { if (pw.cleared) { onClearPassword(id); nextHasPassword = false; } else if (pw.input.length > 0) { onSavePassword(id, pw.input); nextHasPassword = true; } } cleaned.hasPassword = nextHasPassword; const next = draft.map((h) => (h.id === id ? cleaned : h)); setDraft(next); onSave(next.map(({ hasPassword: _hp, ...rest }) => rest)); // Drop the pw draft so re-edit doesn't carry it over. setPwDrafts((cur) => { if (!(id in cur)) return cur; const nxt = { ...cur }; delete nxt[id]; return nxt; }); setEditingId(null); }, [draft, pwDrafts, onSave, onSavePassword, onClearPassword], ); const removeRow = useCallback( (id: string) => { const next = draft.filter((h) => h.id !== id); setDraft(next); // Strip hasPassword on persist — the backend recomputes it. (The // save command sweeps orphan credentials, so the deleted host's // password is also removed from keyring.) onSave(next.map(({ hasPassword: _hp, ...rest }) => rest)); if (editingId === id) setEditingId(null); setPwDrafts((cur) => { if (!(id in cur)) return cur; const nxt = { ...cur }; delete nxt[id]; return nxt; }); }, [draft, editingId, onSave], ); const addRow = useCallback(() => { const fresh = blankHost(); setDraft((cur) => [...cur, fresh]); setEditingId(fresh.id); setPwDrafts((cur) => ({ ...cur, [fresh.id]: { input: "", cleared: false }, })); }, []); return (
e.stopPropagation()} role="dialog" aria-modal="true" aria-label="Manage SSH hosts" >
SSH hosts
{draft.length === 0 ? (

No saved hosts. Click Add host to create one.

) : (
    {draft.map((h) => (
  • {editingId === h.id ? (
    saveRow(h.id, e)}>
    onPasswordInput(h.id, v)} onClear={() => onPasswordClear(h.id)} />
    ) : (
    {h.label || h.hostname} {h.hasPassword && ( 🔒 )}
    {h.user ? `${h.user}@` : ""} {h.hostname} {h.port ? `:${h.port}` : ""} {h.jumpHost ? ` via ${h.jumpHost}` : ""}
    )}
  • ))}
)}
); } function PasswordField({ hostHasPassword, draft, onChange, onClear, }: { hostHasPassword: boolean; draft: PasswordDraft | undefined; onChange: (value: string) => void; onClear: () => void; }) { const [reveal, setReveal] = useState(false); const cleared = draft?.cleared ?? false; const showClearButton = hostHasPassword && !cleared; const placeholder = cleared ? "(password will be removed on save)" : hostHasPassword ? "(saved — leave blank to keep, or type new)" : "password (optional)"; return ( ); }