From 9b3bc9230be503b0235bfd74e48ff66c1b7c4489 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 30 Aug 2026 16:24:22 +0000 Subject: [PATCH] feat(desktop): a server manager you can browse and edit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app could **create** a connection and nothing else. No list, no edit, no delete, no rename. "New server" was a one-way door: get a field wrong and your only recourse was the CLI. And every field beyond the six in that dialog was unreachable from the window entirely — **tags** most consequentially, since fleet selection is built on `--on tag:production` and there was nowhere in the app to set one. Same for the jump host, a non-standard remote rsync path, notes and agent forwarding. So: a manager, in the shape the job actually has. Every server on the left with its tags, every field of the selected one on the right, and New / Duplicate / Import / Save / Test / Delete. One list shared by both tabs, because servers are shared — the same list backs the transfer panes and Fleet. `~/.ssh/config` hosts are listed but not editable; that is somebody else's file. "Save a copy" turns one into a connection of your own, which is the same deliberate act the CLI's import performs, and leaves the file untouched. Editing upserts on the connection id, so changing a name updates the row rather than leaving a second copy behind. Also fixes the smaller half of the same complaint. Saving a fleet command under an existing name has always updated it — the store upserts — but the control read "Save these settings" either way, so there was no way to tell an edit from a new one. It now reads "Update " when what is on screen is a saved command, and prefills the name so one keypress is enough. The renderer's `Connection` type carried only the eight fields the two-pane view happened to need, which is why the rest were uneditable: they were absent from the type, so nothing could render them. It now describes what a connection actually holds. 517 tests. Verified by driving the built renderer: the list, the tags, the full form, and selecting a different server loading it for editing. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01UeSWg1Czsb2Lwxj8vHUnA4 --- apps/desktop/src/app/page.tsx | 18 +- apps/desktop/src/components/fleet-view.tsx | 19 +- .../desktop/src/components/server-manager.tsx | 498 ++++++++++++++++++ apps/desktop/src/lib/api.ts | 14 +- docs/desktop.md | 16 + 5 files changed, 557 insertions(+), 8 deletions(-) create mode 100644 apps/desktop/src/components/server-manager.tsx diff --git a/apps/desktop/src/app/page.tsx b/apps/desktop/src/app/page.tsx index edeac6d..2ac044f 100644 --- a/apps/desktop/src/app/page.tsx +++ b/apps/desktop/src/app/page.tsx @@ -18,6 +18,7 @@ import { import { ConnectionDialog } from '@/components/connection-dialog' import { FleetView } from '@/components/fleet-view' import { ProfileBar } from '@/components/profile-bar' +import { ServerManager } from '@/components/server-manager' import { endpointLabel, loadPane, Pane, type PaneEndpoint, type PaneState } from '@/components/pane' import { TransferRail } from '@/components/transfer-rail' import { MirrorPreviewDialog, TransferBand, type ActiveJob } from '@/components/transfer-panel' @@ -90,6 +91,7 @@ export default function Workspace() { const [job, setJob] = useState(null) const [error, setError] = useState(null) const [showConnection, setShowConnection] = useState(false) + const [showServers, setShowServers] = useState(false) const [tab, setTab] = useState<'transfer' | 'fleet'>('transfer') const [profiles, setProfiles] = useState([]) const [outsideShell, setOutsideShell] = useState(false) @@ -380,11 +382,11 @@ export default function Workspace() {
{/* @@ -408,8 +410,8 @@ export default function Workspace() { } - onClick={() => setShowConnection(true)} - label="Add a server…" + onClick={() => setShowServers(true)} + label="Servers…" /> } @@ -543,9 +545,15 @@ export default function Workspace() {
- setShowConnection(true)} /> + setShowServers(true)} />
+ setShowServers(false)} + onChanged={() => void refreshConnections()} + /> + setShowConnection(false)} onSaved={() => void refreshConnections()} /> void }) { const canRun = selected.size > 0 && script.trim().length > 0 && !running + /** + * Is what is on screen a saved command you are changing? + * + * Saving under an existing name has always updated it — the store upserts — + * but the control said "Save these settings" either way, so there was no way + * to tell an edit from a new one. + */ + const editingSaved = commands.some((command) => !command.builtin && command.name === label) + return (
{error ? ( @@ -563,12 +572,18 @@ export function FleetView({ onAddServer }: { onAddServer: () => void }) { {script.trim() && !savingCommand ? ( ) : null} diff --git a/apps/desktop/src/components/server-manager.tsx b/apps/desktop/src/components/server-manager.tsx new file mode 100644 index 0000000..a1c94cd --- /dev/null +++ b/apps/desktop/src/components/server-manager.tsx @@ -0,0 +1,498 @@ +'use client' + +import { useCallback, useEffect, useMemo, useState } from 'react' +import { CircleAlert, Copy, FileDown, Plus, Search, Server, ShieldCheck, Trash2 } from 'lucide-react' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/checkbox' +import { Dialog, DialogContent } from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { ScrollArea } from '@/components/ui/scroll-area' +import { api, unwrap, type Connection } from '@/lib/api' + +/** + * The server manager: browse everything you have saved, and edit any of it. + * + * The app could *create* a connection and nothing else — no list, no edit, no + * delete. Every field beyond the six in the New-server dialog was unreachable, + * `tags` most consequentially: fleet selection is built on `tag:production` + * and there was nowhere to set one. + * + * Servers are shared by both surfaces, so this is one manager rather than two: + * the same list backs the transfer panes and the Fleet view. + * + * `~/.ssh/config` hosts are listed but not editable — they are somebody else's + * file. "Save a copy" turns one into a connection of your own, which is the + * same deliberate act the CLI's import performs. + */ + +type Draft = { + id: string | null + name: string + host: string + port: string + username: string + authType: 'agent' | 'key' + keyPath: string + defaultRemotePath: string + jumpHost: string + rsyncPath: string + tags: string + notes: string + forwardAgent: boolean +} + +const BLANK: Draft = { + id: null, + name: '', + host: '', + port: '22', + username: '', + authType: 'agent', + keyPath: '', + defaultRemotePath: '', + jumpHost: '', + rsyncPath: '', + tags: '', + notes: '', + forwardAgent: false, +} + +function toDraft(connection: Connection): Draft { + return { + id: connection.id, + name: connection.name, + host: connection.host, + port: String(connection.port), + username: connection.username, + authType: connection.authType === 'key' ? 'key' : 'agent', + keyPath: connection.keyPath ?? '', + defaultRemotePath: connection.defaultRemotePath ?? '', + jumpHost: connection.jumpHost ?? '', + rsyncPath: connection.rsyncPath ?? '', + tags: (connection.tags ?? []).join(', '), + notes: connection.notes ?? '', + forwardAgent: connection.forwardAgent ?? false, + } +} + +export function ServerManager({ open, onClose, onChanged }: { open: boolean; onClose: () => void; onChanged: () => void }) { + const [saved, setSaved] = useState([]) + const [sshConfig, setSshConfig] = useState([]) + const [filter, setFilter] = useState('') + const [draft, setDraft] = useState(BLANK) + /** Null while editing an ssh_config host, which is read-only. */ + const [readOnly, setReadOnly] = useState(false) + const [error, setError] = useState(null) + const [probe, setProbe] = useState(null) + const [busy, setBusy] = useState(false) + + const refresh = useCallback(async () => { + try { + const [list, hosts] = await Promise.all([ + unwrap(api()?.connections.list()), + unwrap(api()?.connections.sshConfigHosts()), + ]) + setSaved(list) + setSshConfig(hosts) + return list + } catch (caught) { + setError(caught instanceof Error ? caught.message : String(caught)) + return [] + } + }, []) + + useEffect(() => { + if (!open) return + void (async () => { + const list = await refresh() + // Land on something rather than an empty form: the first server if there + // is one, otherwise a blank ready to fill in. + setDraft(list[0] ? toDraft(list[0]) : BLANK) + setReadOnly(false) + setError(null) + setProbe(null) + })() + }, [open, refresh]) + + const matches = useCallback( + (connection: Connection) => { + const needle = filter.trim().toLowerCase() + if (!needle) return true + return [connection.name, connection.host, connection.username, ...(connection.tags ?? [])] + .join(' ') + .toLowerCase() + .includes(needle) + }, + [filter], + ) + + const visibleSaved = useMemo(() => saved.filter(matches), [saved, matches]) + const visibleConfig = useMemo(() => sshConfig.filter(matches), [sshConfig, matches]) + + const select = (connection: Connection, fromSshConfig: boolean) => { + setDraft(toDraft(connection)) + setReadOnly(fromSshConfig) + setError(null) + setProbe(null) + } + + const save = useCallback(async () => { + setBusy(true) + setError(null) + try { + const saved = await unwrap( + api()?.connections.save({ + // Present when editing, absent when creating. The store upserts on + // it, so an edit never leaves a second copy behind. + ...(draft.id ? { id: draft.id } : {}), + name: draft.name.trim(), + host: draft.host.trim(), + username: draft.username.trim(), + port: Number(draft.port) || 22, + authType: draft.authType, + keyPath: draft.authType === 'key' ? draft.keyPath.trim() || null : null, + defaultRemotePath: draft.defaultRemotePath.trim() || null, + jumpHost: draft.jumpHost.trim() || null, + rsyncPath: draft.rsyncPath.trim() || null, + forwardAgent: draft.forwardAgent, + tags: draft.tags + .split(',') + .map((tag) => tag.trim()) + .filter(Boolean), + notes: draft.notes, + }), + ) + setDraft(toDraft(saved)) + setReadOnly(false) + await refresh() + onChanged() + } catch (caught) { + setError(caught instanceof Error ? caught.message : String(caught)) + } finally { + setBusy(false) + } + }, [draft, refresh, onChanged]) + + const remove = useCallback(async () => { + if (!draft.id) return + setBusy(true) + setError(null) + try { + await unwrap(api()?.connections.remove(draft.id)) + const list = await refresh() + setDraft(list[0] ? toDraft(list[0]) : BLANK) + onChanged() + } catch (caught) { + setError(caught instanceof Error ? caught.message : String(caught)) + } finally { + setBusy(false) + } + }, [draft.id, refresh, onChanged]) + + const test = useCallback(async () => { + if (!draft.id) return + setBusy(true) + setError(null) + setProbe(null) + try { + const report = (await unwrap(api()?.connections.test(draft.id))) as { + sftp?: boolean + rsync?: boolean + rsyncVersion?: string | null + } + setProbe( + `SSH ok · SFTP ${report.sftp ? 'ok' : 'unavailable'} · rsync ${ + report.rsync ? (report.rsyncVersion ?? 'ok') : 'not found' + }`, + ) + } catch (caught) { + setError(caught instanceof Error ? caught.message : String(caught)) + } finally { + setBusy(false) + } + }, [draft.id]) + + const set = (key: K, value: Draft[K]) => setDraft((current) => ({ ...current, [key]: value })) + const canSave = draft.name.trim() !== '' && draft.host.trim() !== '' && draft.username.trim() !== '' && !busy + + return ( + !next && onClose()}> + +
+ +
+

Servers

+

+ Used by both the transfer panes and Fleet. +

+
+
+ + {error ? ( +
+ + {error} +
+ ) : null} + +
+ {/* --- the list ------------------------------------------------ */} + + + {/* --- the form ------------------------------------------------ */} +
+ +
+ {readOnly ? ( +

+ This one comes from ~/.ssh/config and is not edited here. Save a copy to make it + yours — the file stays untouched. +

+ ) : null} + +
+ + set('name', e.target.value)} disabled={readOnly} className="h-8 text-[12.5px]" /> + + + set('tags', e.target.value)} disabled={readOnly} placeholder="production, web" className="h-8 text-[12.5px]" /> + + + set('host', e.target.value)} disabled={readOnly} className="h-8 text-[12.5px]" /> + + + set('port', e.target.value)} disabled={readOnly} className="h-8 text-[12.5px]" /> + + + set('username', e.target.value)} disabled={readOnly} className="h-8 text-[12.5px]" /> + + + + + {draft.authType === 'key' ? ( + + set('keyPath', e.target.value)} disabled={readOnly} placeholder="~/.ssh/id_ed25519" className="h-8 text-[12.5px]" /> + + ) : null} + + set('defaultRemotePath', e.target.value)} disabled={readOnly} placeholder="/srv/app" className="h-8 text-[12.5px]" /> + + + set('jumpHost', e.target.value)} disabled={readOnly} className="h-8 text-[12.5px]" /> + + + set('rsyncPath', e.target.value)} disabled={readOnly} className="h-8 text-[12.5px]" /> + + + set('notes', e.target.value)} disabled={readOnly} className="h-8 text-[12.5px]" /> + +
+ + + + {probe ? ( +

+ + {probe} +

+ ) : null} +
+
+ +
+ + + {draft.id && !readOnly ? ( + + ) : null} +
+
+
+
+
+ ) +} + +function Row({ + connection, + active, + muted, + onClick, +}: { + connection: Connection + active: boolean + muted?: boolean + onClick: () => void +}) { + return ( + + ) +} + +function Field({ + label, + hint, + className, + children, +}: { + label: string + hint?: string + className?: string + children: React.ReactNode +}) { + return ( +
+ + {children} + {hint ?

{hint}

: null} +
+ ) +} diff --git a/apps/desktop/src/lib/api.ts b/apps/desktop/src/lib/api.ts index 5ec81a2..6a24ec8 100644 --- a/apps/desktop/src/lib/api.ts +++ b/apps/desktop/src/lib/api.ts @@ -31,7 +31,19 @@ export type Connection = { authType: string defaultRemotePath: string | null defaultLocalPath: string | null - /** Optional because the two-pane view predates them and never asked for them. */ + /* + * The rest of what a connection actually holds. The two-pane view never + * asked for them, so they were absent from this type and therefore + * uneditable anywhere in the app — `tags` most consequentially, since + * `--on tag:production` is the thing fleet selection is built around. + */ + keyPath?: string | null + jumpHost?: string | null + rsyncPath?: string | null + notes?: string + forwardAgent?: boolean + connectTimeoutSeconds?: number + keepaliveSeconds?: number | null tags?: string[] } diff --git a/docs/desktop.md b/docs/desktop.md index 7a99882..e540847 100644 --- a/docs/desktop.md +++ b/docs/desktop.md @@ -37,6 +37,22 @@ Skip unchanged files: On Delete destination-only files: Off ``` +## Servers + +**Servers** in the header opens the manager: every saved server on the left, +every field of the selected one on the right. New, Duplicate, Import from +`~/.ssh/config`, Save, Test, Delete. It is one list shared by both tabs — the +same servers back the transfer panes and Fleet. + +Fields the New-server dialog never exposed are here, **tags** most usefully: +Fleet selects on them (`--on tag:production`), and there was previously +nowhere in the app to set one. Also the jump host, a non-standard remote rsync +path, notes, and agent forwarding. + +`~/.ssh/config` hosts are listed but not edited — that is somebody else's +file. **Save a copy** turns one into a connection of your own and leaves the +file alone. + ## Saved pairs The strip above the panes holds saved profiles: click one to restore its