Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion apps/desktop/electron/main/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
JobIdSchema,
PathSchema,
RemotePathRequestSchema,
ProfileSaveSchema,
RenameEntryRequestSchema,
TransferRequestSchema,
type IpcResult,
Expand All @@ -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
Expand Down Expand Up @@ -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 ---------------------------------------------------------------
Expand Down
32 changes: 32 additions & 0 deletions apps/desktop/electron/main/services/transfers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion apps/desktop/electron/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean>(IPC.profilesRemove, { id }),
},
fleet: {
servers: () => call(IPC.fleetServers),
Expand Down
17 changes: 17 additions & 0 deletions apps/desktop/electron/shared/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,23 @@ export type TransferRequest = z.infer<typeof TransferRequestSchema>

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,
Expand Down
66 changes: 65 additions & 1 deletion apps/desktop/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -90,6 +91,7 @@ export default function Workspace() {
const [error, setError] = useState<string | null>(null)
const [showConnection, setShowConnection] = useState(false)
const [tab, setTab] = useState<'transfer' | 'fleet'>('transfer')
const [profiles, setProfiles] = useState<SyncProfile[]>([])
const [outsideShell, setOutsideShell] = useState(false)

const refreshConnections = useCallback(async () => {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -393,6 +449,14 @@ export default function Workspace() {
*/}
{tab === 'transfer' ? (
<>
<ProfileBar
profiles={profiles}
routeLabel={route}
busy={job !== null && !job.finished}
onLoad={loadProfile}
onSave={(name) => void saveProfile(name)}
onRemove={(id) => void removeProfile(id)}
/>
<div className="flex min-h-0 flex-1 gap-0 p-3.5">
<Pane
role="Source"
Expand Down
144 changes: 144 additions & 0 deletions apps/desktop/src/components/profile-bar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
'use client'

import { useState } from 'react'
import { BookmarkPlus, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import type { SyncProfile } from '@/lib/api'

/**
* Saved transfer setups, as a strip above the panes.
*
* A profile is a source, a destination and the options — the thing an SFTP
* client calls a saved site. The pieces existed: the table, the CLI, and an
* IPC channel. The channel had no handler and nothing in the window called
* it, so the app could list profiles and delete them but never make one.
*
* Deliberately the same shape as the Fleet view's command strip: chips you
* click to restore, an × to delete, and one control to save what is on screen.
* Two lists of saved things that behaved differently would be two things to
* learn.
*/
export function ProfileBar({
profiles,
routeLabel,
busy,
onLoad,
onSave,
onRemove,
}: {
profiles: readonly SyncProfile[]
/** What would be saved, e.g. `This computer → web-01`. Shown while naming. */
routeLabel: string
busy: boolean
onLoad: (profile: SyncProfile) => 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 (
<div className="flex h-[30px] shrink-0 items-center gap-2 border-b border-line px-4">
<button
type="button"
onClick={() => setNaming(true)}
disabled={busy}
className="focus-ring inline-flex items-center gap-1.5 rounded-md border border-dashed border-line-strong px-2 py-0.5 text-[11px] text-muted-foreground transition-colors hover:text-foreground disabled:opacity-50"
>
<BookmarkPlus className="size-3" />
Save this pair
</button>
</div>
)
}

return (
<div className="flex min-h-[30px] shrink-0 flex-wrap items-center gap-1.5 border-b border-line px-4 py-1">
<span className="mr-1 text-[11px] text-faint">Saved</span>

{profiles.map((profile) => (
<span
key={profile.id}
className="group/profile inline-flex items-center overflow-hidden rounded-md border border-line-strong"
>
<button
type="button"
title={`${describe(profile.source)} → ${describe(profile.destination)}${
profile.options?.deleteMode && profile.options.deleteMode !== 'off' ? ' · deletes ON' : ''
}`}
onClick={() => onLoad(profile)}
disabled={busy}
className="focus-ring px-2 py-0.5 text-[11px] text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground disabled:opacity-50"
>
{profile.name}
{profile.options?.deleteMode && profile.options.deleteMode !== 'off' ? (
// Worth a mark of its own. Loading a profile that turns Mirror
// on should not be something you discover from the footer.
<span className="ml-1 text-destructive">mirror</span>
) : null}
</button>
<button
type="button"
aria-label={`Delete the profile ${profile.name}`}
title={`Delete the saved profile ${profile.name}`}
onClick={() => onRemove(profile.id)}
disabled={busy}
className="focus-ring pr-1 text-faint opacity-0 transition-opacity hover:text-destructive group-hover/profile:opacity-100"
>
<X className="size-2.5" />
</button>
</span>
))}

{naming ? (
<span className="inline-flex items-center gap-1">
<Input
autoFocus
value={name}
onChange={(event) => 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]"
/>
<Button size="xs" onClick={commit} disabled={!name.trim()} className="text-[11px]">
Save
</Button>
<span className="text-[10.5px] text-faint">{routeLabel}</span>
</span>
) : (
<button
type="button"
onClick={() => setNaming(true)}
disabled={busy}
className="focus-ring inline-flex items-center gap-1 rounded-md border border-dashed border-line-strong px-2 py-0.5 text-[11px] text-muted-foreground transition-colors hover:text-foreground disabled:opacity-50"
>
<BookmarkPlus className="size-2.5" />
Save this pair
</button>
)}
</div>
)
}

function describe(endpoint: SyncProfile['source']): string {
if (endpoint.type === 'local') return endpoint.path
return `${endpoint.host}:${endpoint.path}`
}
20 changes: 19 additions & 1 deletion apps/desktop/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -199,7 +208,16 @@ type Api = {
cancel(jobId: string): Promise<IpcResult<boolean>>
list(limit?: number): Promise<IpcResult<unknown[]>>
}
profiles: { list(): Promise<IpcResult<unknown[]>>; remove(id: string): Promise<IpcResult<boolean>> }
profiles: {
list(): Promise<IpcResult<SyncProfile[]>>
save(input: {
name: string
source: unknown
destination: unknown
options: { deleteMode: 'off' | 'delay' }
}): Promise<IpcResult<SyncProfile>>
remove(id: string): Promise<IpcResult<boolean>>
}
fleet: {
servers(): Promise<IpcResult<Connection[]>>
commands(): Promise<IpcResult<FleetCommand[]>>
Expand Down
Loading
Loading