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
209
src/components/HostManager.css
Normal file
209
src/components/HostManager.css
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
.host-mgr-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.host-mgr-panel {
|
||||
background: #161616;
|
||||
color: #ccc;
|
||||
border: 1px solid #2a2a2a;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 10px 32px rgba(0, 0, 0, 0.7);
|
||||
width: min(620px, 96vw);
|
||||
max-height: 86vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
|
||||
}
|
||||
|
||||
.host-mgr-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid #2a2a2a;
|
||||
}
|
||||
.host-mgr-title {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
.host-mgr-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #888;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
padding: 2px 8px;
|
||||
cursor: pointer;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.host-mgr-close:hover {
|
||||
background: #2a2a2a;
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
.host-mgr-body {
|
||||
overflow-y: auto;
|
||||
padding: 12px 14px;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.host-mgr-empty {
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.host-mgr-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.host-row {
|
||||
background: #1c1c1c;
|
||||
border: 1px solid #2a2a2a;
|
||||
border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.host-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.host-summary-label {
|
||||
font-weight: 600;
|
||||
color: #e6e6e6;
|
||||
font-size: 12px;
|
||||
}
|
||||
.host-summary-detail {
|
||||
color: #888;
|
||||
font-size: 11px;
|
||||
margin-top: 1px;
|
||||
}
|
||||
.host-edit-btn {
|
||||
background: #222;
|
||||
color: #aac;
|
||||
border: 1px solid #2a2a3a;
|
||||
border-radius: 3px;
|
||||
padding: 3px 10px;
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.host-edit-btn:hover {
|
||||
background: #2a2a3a;
|
||||
color: #cce;
|
||||
}
|
||||
|
||||
.host-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.host-form label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
color: #888;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.host-form input {
|
||||
font: inherit;
|
||||
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
|
||||
font-size: 12px;
|
||||
background: #0c0c0c;
|
||||
color: #e6e6e6;
|
||||
border: 1px solid #2a2a2a;
|
||||
border-radius: 3px;
|
||||
padding: 4px 6px;
|
||||
outline: none;
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
}
|
||||
.host-form input:focus {
|
||||
border-color: #3a5a8c;
|
||||
}
|
||||
.host-form-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.host-form-row > label {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
.host-form-port {
|
||||
flex: 0 0 90px !important;
|
||||
}
|
||||
.host-form .required {
|
||||
color: #d66;
|
||||
}
|
||||
.host-form-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.host-form-actions button {
|
||||
font: inherit;
|
||||
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
|
||||
font-size: 11px;
|
||||
padding: 4px 12px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
background: #222;
|
||||
color: #ccc;
|
||||
border: 1px solid #2a2a2a;
|
||||
}
|
||||
.host-form-actions button:hover {
|
||||
background: #2a2a2a;
|
||||
}
|
||||
.host-form-actions button.primary {
|
||||
background: #1a3a5c;
|
||||
color: #cce6ff;
|
||||
border-color: #3a5a8c;
|
||||
}
|
||||
.host-form-actions button.primary:hover {
|
||||
background: #245080;
|
||||
}
|
||||
.host-form-actions button.danger {
|
||||
margin-left: auto;
|
||||
color: #d88;
|
||||
border-color: #3a1a1a;
|
||||
}
|
||||
.host-form-actions button.danger:hover {
|
||||
background: #3a1a1a;
|
||||
color: #fcc;
|
||||
}
|
||||
|
||||
.host-add-btn {
|
||||
margin-top: 10px;
|
||||
font: inherit;
|
||||
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
|
||||
font-size: 11px;
|
||||
background: #1c1c1c;
|
||||
color: #88c;
|
||||
border: 1px dashed #3a3a4a;
|
||||
border-radius: 4px;
|
||||
padding: 6px 10px;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
.host-add-btn:hover {
|
||||
background: #222;
|
||||
color: #aac;
|
||||
border-color: #4a4a5a;
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@ import {
|
|||
onPaneData,
|
||||
onPaneExit,
|
||||
type PaneId,
|
||||
type SpawnSpec,
|
||||
} from "../ipc";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -45,8 +46,10 @@ function stringToB64(s: string): string {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface XtermPaneProps {
|
||||
distro?: string;
|
||||
cwd?: string;
|
||||
/** Spec describing what to spawn into this pane's PTY. Read once at mount;
|
||||
* changing it later does NOT respawn — callers force a respawn by
|
||||
* changing the React `key` (see Pane.svelte / LeafPane). */
|
||||
spec: SpawnSpec;
|
||||
onStatus?: (msg: string, ok: boolean) => void;
|
||||
/** Fired once when the backend PTY is alive and we have its PaneId. */
|
||||
onSpawn?: (paneId: PaneId) => void;
|
||||
|
|
@ -69,8 +72,7 @@ const DEFAULT_XTERM_FONT_SIZE = 13;
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function XtermPane({
|
||||
distro,
|
||||
cwd,
|
||||
spec,
|
||||
onStatus,
|
||||
onSpawn,
|
||||
onInput,
|
||||
|
|
@ -152,7 +154,7 @@ export default function XtermPane({
|
|||
const rows = term!.rows;
|
||||
|
||||
try {
|
||||
paneId = await spawnPane({ distro, cwd, cols, rows });
|
||||
paneId = await spawnPane({ spec, cols, rows });
|
||||
if (destroyed) {
|
||||
void killPane(paneId);
|
||||
return;
|
||||
|
|
@ -287,8 +289,9 @@ export default function XtermPane({
|
|||
fitRef.current = null;
|
||||
paneIdRef.current = null;
|
||||
};
|
||||
// distro/cwd are only used at spawn time; intentionally omitted from deps
|
||||
// so remounting doesn't happen if a parent re-renders with the same values.
|
||||
// spec is read once at mount; intentionally omitted from deps so we
|
||||
// don't remount on parent re-renders. Callers force a respawn by
|
||||
// bumping the React `key` (changeShell swaps the leaf id for that).
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue