From 2d7241d235b1db5c2bc42d28de71601cc21a2b7d Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 30 Aug 2026 14:38:22 +0000 Subject: [PATCH] feat(desktop): save a transfer as a profile, from the app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `sync_profiles` has existed since the first migration, the CLI has driven it since the first release, and `IPC.profilesSave` has been in the contract the whole time — with **no handler behind it**, and nothing in the window calling it. The app could list profiles and delete them but could never make one, so the feature was invisible from the only surface most people use. The strip above the two panes holds them now: click one to restore its source, destination and options, "Save this pair" stores what is on screen, and the x deletes it. Details worth knowing: - Loading a profile sets Mirror to whatever the profile stored, rather than leaving it as it found it. A profile that did something different depending on what you had toggled last would not be a profile. - A profile with deletes enabled carries a red "mirror" mark on its chip. Loading something that turns Mirror on is not a thing to discover from the footer afterwards. - `trustDeletes` is never set from the app. It is the one way a mirror runs without a human seeing the delete list first, so it stays a deliberate, out-of-band choice. - The renderer sends the same endpoint references a transfer takes, so a profile can name a saved connection but never a host of its own. The main process resolves them, which is what makes the stored profile the same shape the CLI writes. The strip is deliberately the same shape as the Fleet view's command strip: chips to restore, an x to delete, one control to save. Two lists of saved things that behaved differently would be two things to learn. Verified rather than asserted: saved a profile through the store exactly as the IPC handler builds it, then listed and **ran** it from the CLI, and confirmed the file landed. Saved in the app, run from the terminal, one store. The strip itself was screenshotted, including the mirror mark. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01UeSWg1Czsb2Lwxj8vHUnA4 --- apps/desktop/electron/main/ipc.ts | 5 +- .../electron/main/services/transfers.ts | 32 ++++ apps/desktop/electron/preload/index.ts | 3 +- apps/desktop/electron/shared/contract.ts | 17 +++ apps/desktop/src/app/page.tsx | 66 +++++++- apps/desktop/src/components/profile-bar.tsx | 144 ++++++++++++++++++ apps/desktop/src/lib/api.ts | 20 ++- docs/desktop.md | 10 ++ docs/profiles.md | 24 ++- 9 files changed, 315 insertions(+), 6 deletions(-) create mode 100644 apps/desktop/src/components/profile-bar.tsx diff --git a/apps/desktop/electron/main/ipc.ts b/apps/desktop/electron/main/ipc.ts index 71b7530..c9bdc9d 100644 --- a/apps/desktop/electron/main/ipc.ts +++ b/apps/desktop/electron/main/ipc.ts @@ -18,6 +18,7 @@ import { JobIdSchema, PathSchema, RemotePathRequestSchema, + ProfileSaveSchema, RenameEntryRequestSchema, TransferRequestSchema, type IpcResult, @@ -38,7 +39,7 @@ import { } from './services/fleet.js' import { browserFor, dropSession, sessionFor } from './services/sessions.js' import { store } from './services/store.js' -import { cancelTransfer, previewTransfer, startTransfer } from './services/transfers.js' +import { cancelTransfer, previewTransfer, saveProfile, startTransfer } from './services/transfers.js' /** * Every handler validates its input with Zod before doing anything, and every @@ -309,6 +310,8 @@ export function registerIpc(): void { handle(IPC.profilesList, z.undefined(), async () => (await store()).listProfiles()) + handle(IPC.profilesSave, ProfileSaveSchema, async (input) => saveProfile(input)) + handle(IPC.profilesRemove, z.object({ id: z.string().min(1) }), async ({ id }) => (await store()).deleteProfile(id)) // --- fleet --------------------------------------------------------------- diff --git a/apps/desktop/electron/main/services/transfers.ts b/apps/desktop/electron/main/services/transfers.ts index df86f66..c662ed1 100644 --- a/apps/desktop/electron/main/services/transfers.ts +++ b/apps/desktop/electron/main/services/transfers.ts @@ -226,6 +226,38 @@ export async function startTransfer(request: TransferRequest, sender: WebContent return { jobId, command: plan.display, control: plan.controlDisplay ?? null, warnings: plan.warnings } } +/** + * Saves the current pair and options as a named profile. + * + * The endpoint references are resolved to full endpoints here, so the profile + * that lands in the store is the same shape the CLI writes and can be run with + * `diskpush profile run NAME`. One store, one profile, either surface. + */ +export async function saveProfile(input: { + name: string + source: EndpointRef + destination: EndpointRef + options: TransferOptions +}) { + const source = await resolveEndpoint(input.source) + const destination = await resolveEndpoint(input.destination) + return (await store()).saveProfile({ + name: input.name, + source: source.endpoint, + destination: destination.endpoint, + preset: 'fast-sync', + options: optionsFrom(input.options), + // Never set from the app. Unattended mirroring is the one way a delete + // list runs without a human looking at it, and it stays a deliberate, + // out-of-band choice. + trustDeletes: false, + schedule: { enabled: false, kind: 'daily', cron: null }, + watch: { enabled: false, debounceMs: 1000 }, + notifyOnSuccess: false, + notifyOnFailure: true, + }) +} + export function cancelTransfer(jobId: string): boolean { const job = running.get(jobId) if (!job) return false diff --git a/apps/desktop/electron/preload/index.ts b/apps/desktop/electron/preload/index.ts index fcfae96..97447d7 100644 --- a/apps/desktop/electron/preload/index.ts +++ b/apps/desktop/electron/preload/index.ts @@ -47,7 +47,8 @@ const api = { }, profiles: { list: () => call(IPC.profilesList), - remove: (id: string) => call(IPC.profilesRemove, { id }), + save: (input: unknown) => call(IPC.profilesSave, input), + remove: (id: string) => call(IPC.profilesRemove, { id }), }, fleet: { servers: () => call(IPC.fleetServers), diff --git a/apps/desktop/electron/shared/contract.ts b/apps/desktop/electron/shared/contract.ts index 9ae8706..c550951 100644 --- a/apps/desktop/electron/shared/contract.ts +++ b/apps/desktop/electron/shared/contract.ts @@ -128,6 +128,23 @@ export type TransferRequest = z.infer export const JobIdSchema = z.string().uuid() +/** + * Saving the current pane pair and options as a named profile. + * + * The renderer sends the same endpoint references a transfer takes, so a + * profile can name a saved connection but never a host of its own. The main + * process resolves them and stores the full endpoints, which is what makes a + * profile runnable from the CLI too. + */ +export const ProfileSaveSchema = z.object({ + name: z.string().min(1).max(128).refine((value) => value.trim() === value, { + message: 'A name cannot begin or end with a space.', + }), + source: EndpointRefSchema, + destination: EndpointRefSchema, + options: TransferOptionsSchema, +}) + export const RemotePathRequestSchema = z.object({ connectionId: ConnectionIdSchema, path: PathSchema, diff --git a/apps/desktop/src/app/page.tsx b/apps/desktop/src/app/page.tsx index fdefa83..6bc37fe 100644 --- a/apps/desktop/src/app/page.tsx +++ b/apps/desktop/src/app/page.tsx @@ -17,12 +17,13 @@ import { } from 'lucide-react' import { ConnectionDialog } from '@/components/connection-dialog' import { FleetView } from '@/components/fleet-view' +import { ProfileBar } from '@/components/profile-bar' 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' import { Button } from '@/components/ui/button' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' -import { api, unwrap, type Connection, type PreviewResult, type TransferEvent } from '@/lib/api' +import { api, unwrap, type Connection, type PreviewResult, type SyncProfile, type TransferEvent } from '@/lib/api' import { withTrailingSlash } from '@/lib/format' /** A row in the header menu. Plain button, styled once. */ @@ -90,6 +91,7 @@ export default function Workspace() { const [error, setError] = useState(null) const [showConnection, setShowConnection] = useState(false) const [tab, setTab] = useState<'transfer' | 'fleet'>('transfer') + const [profiles, setProfiles] = useState([]) const [outsideShell, setOutsideShell] = useState(false) const refreshConnections = useCallback(async () => { @@ -133,6 +135,7 @@ export default function Workspace() { ]) setSaved(savedList) setSshConfig(hosts) + setProfiles(await unwrap(api()?.profiles.list())) const first = savedList[0] ?? hosts[0] setRight( @@ -232,6 +235,59 @@ export default function Workspace() { [request], ) + /** + * Restores a saved pair. + * + * The panes are set from the stored endpoints; Mirror follows the stored + * delete mode, because a profile that quietly left Mirror as you found it + * would be a profile that does something different every time. + */ + const loadProfile = useCallback( + (profile: SyncProfile) => { + const toPane = (endpoint: SyncProfile['source']): PaneEndpoint => + endpoint.type === 'local' || !endpoint.connectionId + ? { kind: 'local' } + : { kind: 'ssh', connectionId: endpoint.connectionId } + + setError(null) + setLeft(blankPane(toPane(profile.source), profile.source.path)) + setRight(blankPane(toPane(profile.destination), profile.destination.path)) + setDirection('ltr') + setMirror(profile.options?.deleteMode !== undefined && profile.options.deleteMode !== 'off') + }, + [], + ) + + const saveProfile = useCallback( + async (name: string) => { + setError(null) + try { + await unwrap( + api()?.profiles.save({ + name, + source: request.source, + destination: request.destination, + options: request.options, + }), + ) + setProfiles(await unwrap(api()?.profiles.list())) + } catch (caught) { + setError(caught instanceof Error ? caught.message : String(caught)) + } + }, + [request], + ) + + const removeProfile = useCallback(async (id: string) => { + setError(null) + try { + await unwrap(api()?.profiles.remove(id)) + setProfiles(await unwrap(api()?.profiles.list())) + } catch (caught) { + setError(caught instanceof Error ? caught.message : String(caught)) + } + }, []) + const run = useCallback(async () => { // Mirror always previews. A plain sync does not: its dry run costs a full // scan and buys no safety, because nothing is deleted either way. @@ -393,6 +449,14 @@ export default function Workspace() { */} {tab === 'transfer' ? ( <> + void saveProfile(name)} + onRemove={(id) => void removeProfile(id)} + />
void + onSave: (name: string) => void + onRemove: (id: string) => void +}) { + const [naming, setNaming] = useState(false) + const [name, setName] = useState('') + + const commit = () => { + const trimmed = name.trim() + if (!trimmed) return + onSave(trimmed) + setName('') + setNaming(false) + } + + // Nothing saved and nothing being saved: no strip at all rather than an + // empty bar explaining itself. + if (profiles.length === 0 && !naming) { + return ( +
+ +
+ ) + } + + return ( +
+ Saved + + {profiles.map((profile) => ( + + + + + ))} + + {naming ? ( + + setName(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') commit() + if (event.key === 'Escape') { + setNaming(false) + setName('') + } + }} + placeholder="profile name" + className="h-6 w-[150px] text-[11.5px]" + /> + + {routeLabel} + + ) : ( + + )} +
+ ) +} + +function describe(endpoint: SyncProfile['source']): string { + if (endpoint.type === 'local') return endpoint.path + return `${endpoint.host}:${endpoint.path}` +} diff --git a/apps/desktop/src/lib/api.ts b/apps/desktop/src/lib/api.ts index c3b2092..2a9e8c4 100644 --- a/apps/desktop/src/lib/api.ts +++ b/apps/desktop/src/lib/api.ts @@ -53,6 +53,15 @@ export type PreviewResult = { message: string } +/** A saved source/destination pair with its options. Runnable from the CLI too. */ +export type SyncProfile = { + id: string + name: string + source: { type: 'local'; path: string } | { type: 'ssh'; connectionId?: string; host: string; path: string } + destination: { type: 'local'; path: string } | { type: 'ssh'; connectionId?: string; host: string; path: string } + options: { deleteMode: 'off' | 'delay' | 'during' | 'after' | 'before' } +} + export type StartedJob = { jobId: string; command: string; control: string | null; warnings: string[] } export type TransferProgress = { @@ -199,7 +208,16 @@ type Api = { cancel(jobId: string): Promise> list(limit?: number): Promise> } - profiles: { list(): Promise>; remove(id: string): Promise> } + profiles: { + list(): Promise> + save(input: { + name: string + source: unknown + destination: unknown + options: { deleteMode: 'off' | 'delay' } + }): Promise> + remove(id: string): Promise> + } fleet: { servers(): Promise> commands(): Promise> diff --git a/docs/desktop.md b/docs/desktop.md index dd9920c..f85f1b8 100644 --- a/docs/desktop.md +++ b/docs/desktop.md @@ -37,6 +37,16 @@ Skip unchanged files: On Delete destination-only files: Off ``` +## Saved pairs + +The strip above the panes holds saved profiles: click one to restore its +source, destination and options, **Save this pair** stores what is on screen, +and the × deletes it. A profile with deletes enabled carries a red **mirror** +mark. + +These are the same profiles the CLI uses — a pair saved here runs with +`diskpush profile run NAME`. See [profiles.md](profiles.md). + ## Mirror The destination pane stays browsable right up to the confirmation: you can diff --git a/docs/profiles.md b/docs/profiles.md index de325bf..db74d23 100644 --- a/docs/profiles.md +++ b/docs/profiles.md @@ -24,9 +24,29 @@ watch off by default notifyOnSuccess / notifyOnFailure ``` +## In the desktop app + +The strip above the two panes holds them. Click one to restore its source, +destination and options; **Save this pair** stores what is on screen; the × on +a chip deletes it. + +A profile whose delete mode is on carries a red **mirror** mark, because +loading a profile that turns Mirror on is not something to discover from the +footer afterwards. + +Loading a profile sets Mirror to whatever the profile stored, rather than +leaving it as it found it — a profile that did something different depending +on what you had toggled last would not be a profile. + +`trustDeletes` is never set from the app. It is the one way a mirror runs +without a human looking at the delete list first, so it stays a deliberate, +out-of-band choice. + Profiles and connections live in one local database shared with the desktop -app. A profile created in the CLI appears in the app, and the reverse. There is -deliberately no second configuration universe. +app. A profile created in the CLI appears in the app, and the reverse — a pair +saved in the window is runnable with `diskpush profile run NAME`, which is +verified rather than assumed. There is deliberately no second configuration +universe. ## Direction