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