diff --git a/apps/desktop/electron/main/ipc.ts b/apps/desktop/electron/main/ipc.ts index 6a2e12f..bfcfc5c 100644 --- a/apps/desktop/electron/main/ipc.ts +++ b/apps/desktop/electron/main/ipc.ts @@ -17,6 +17,7 @@ import { FleetRunIdSchema, IPC, JobIdSchema, + PreviewRequestSchema, PathSchema, RemotePathRequestSchema, ProfileSaveSchema, @@ -42,7 +43,7 @@ import { } from './services/fleet.js' import { browserFor, dropSession, sessionFor } from './services/sessions.js' import { store } from './services/store.js' -import { cancelTransfer, previewTransfer, saveProfile, startTransfer } from './services/transfers.js' +import { cancelPreview, cancelTransfer, previewTransfer, saveProfile, startTransfer } from './services/transfers.js' /** * Every handler validates its input with Zod before doing anything, and every @@ -299,7 +300,11 @@ export function registerIpc(): void { // --- transfers ----------------------------------------------------------- - handle(IPC.transfersPreview, TransferRequestSchema, async (request) => previewTransfer(request)) + handle(IPC.transfersPreview, PreviewRequestSchema, async (request, event) => previewTransfer(request, event.sender)) + + handle(IPC.transfersPreviewCancel, z.object({ previewId: JobIdSchema }), async ({ previewId }) => + cancelPreview(previewId), + ) handle(IPC.transfersStart, TransferRequestSchema, async (request, event) => startTransfer(request, event.sender)) diff --git a/apps/desktop/electron/main/services/preview.test.ts b/apps/desktop/electron/main/services/preview.test.ts new file mode 100644 index 0000000..714ae95 --- /dev/null +++ b/apps/desktop/electron/main/services/preview.test.ts @@ -0,0 +1,243 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RsyncEvent } from '@diskpush/schemas' + +/** + * A run whose events this test pushes by hand, so a scan can be inspected + * while it is still going -- which is the whole point of what is being tested. + */ +function fakeRun() { + const queue: RsyncEvent[] = [] + const waiters: Array<(result: IteratorResult) => void> = [] + let done = false + let killed = false + + const push = (event: RsyncEvent) => { + const waiter = waiters.shift() + if (waiter) waiter({ value: event, done: false }) + else queue.push(event) + } + const end = () => { + done = true + while (waiters.length > 0) waiters.shift()!({ value: undefined as never, done: true }) + } + + return { + push, + end, + get killed() { + return killed + }, + handle: { + cancel: () => { + killed = true + // A real SIGINT makes rsync exit, which is what closes the stream. + push({ type: 'exit', code: 20, signal: 'SIGINT', resumable: true, message: 'Interrupted.' } as RsyncEvent) + end() + }, + events: { + [Symbol.asyncIterator]() { + return { + next(): Promise> { + const queued = queue.shift() + if (queued) return Promise.resolve({ value: queued, done: false }) + if (done) return Promise.resolve({ value: undefined as never, done: true }) + return new Promise((resolve) => waiters.push(resolve)) + }, + } + }, + }, + }, + } +} + +/** A stable holder, so the mock factory reads whichever run a test installed. */ +const runs = { current: fakeRun() } + +vi.mock('./store.js', () => ({ + store: async () => ({ getSetting: async (_key: string, fallback: T) => fallback }), +})) + +vi.mock('@diskpush/rsync-core', () => ({ + planTransfer: () => ({ + binary: 'rsync', + args: [], + display: 'rsync --dry-run a/ b/', + controlDisplay: null, + warnings: [], + }), + runPlan: () => runs.current.handle, + parseRsyncCapabilities: () => ({}), + intersectCapabilities: (a: unknown) => a, + unknownCapabilities: () => ({}), +})) + +const { PREVIEW_DELETE_LIMIT, cancelAll, cancelPreview, previewTransfer } = await import('./transfers.js') + +const REQUEST = { + source: { type: 'local' as const, path: '/src/' }, + destination: { type: 'local' as const, path: '/dst/' }, + options: { + archive: true, + checksum: false, + compression: 'auto' as const, + deleteMode: 'delay' as const, + hardLinks: false, + acls: false, + xattrs: false, + numericIds: false, + update: false, + ignoreExisting: false, + existingOnly: false, + inplace: false, + excludes: [], + includes: [], + bwlimit: null, + maxSize: null, + minSize: null, + }, + deletesConfirmed: false, +} + +type Progress = { checked: number; total: number; changes: number; deletes: number; currentPath: string } +type Sent = { channel: string; payload: { previewId: string; progress: Progress } } + +function sender() { + const sent: Sent[] = [] + return { + sent, + webContents: { + isDestroyed: () => false, + send: (channel: string, payload: unknown) => sent.push({ channel, payload } as Sent), + } as never, + } +} + +const change = (action: string, path: string): RsyncEvent => + ({ type: 'change', change: { action, path, itemize: null, isDirectory: false, size: null } }) as RsyncEvent + +/** + * Waits for a condition rather than for a duration. + * + * The preview's first await is a real `rsync --version`, so a fixed sleep is a + * race that passes on this machine and fails on a slower one. + */ +async function until(condition: () => boolean): Promise { + for (let attempt = 0; attempt < 400; attempt += 1) { + if (condition()) return + await new Promise((resolve) => setTimeout(resolve, 5)) + } + throw new Error('condition never held') +} + +describe('previewTransfer', () => { + /* + * The bug: the dialog showed a bare spinner for the whole scan, because + * nothing was reported until the dry run finished. A scan of a remote tree + * takes minutes, so every slow preview was indistinguishable from a hang -- + * which is exactly what it was reported as. + */ + it('streams progress while the scan is still running', async () => { + runs.current = fakeRun() + const target = sender() + const pending = previewTransfer({ ...REQUEST, previewId: 'p1' }, target.webContents) + + // Reported before rsync has said anything at all. + await until(() => target.sent.length > 0) + expect(target.sent[0]!.channel).toBe('event:preview') + + runs.current.push({ + type: 'progress', + progress: { + bytesTransferred: 0, + percent: 0, + bytesPerSecond: 0, + elapsedSeconds: 0, + filesTransferred: 1, + filesRemaining: 400, + filesTotal: 1000, + }, + } as RsyncEvent) + runs.current.push(change('add', 'photos/a.jpg')) + + runs.current.push({ type: 'exit', code: 0, signal: null, resumable: false, message: 'Done.' } as RsyncEvent) + runs.current.end() + await pending + + const last = target.sent.at(-1)!.payload + expect(last.previewId).toBe('p1') + // rsync counts down, so 400 of 1000 left means 600 compared. + expect(last.progress.checked).toBe(600) + expect(last.progress.total).toBe(1000) + expect(last.progress.currentPath).toBe('photos/a.jpg') + expect(last.progress.changes).toBe(1) + }) + + /* + * The bug: every change was accumulated and returned. An ordinary source + * tree produces a few hundred thousand of them -- ~70MB across the IPC + * boundary by structured clone, for a field the renderer never read, plus a + * DOM node per delete. The counts are exact; the enumeration is not endless. + */ + it('returns counts rather than the change list, and caps the enumerated deletes', async () => { + runs.current = fakeRun() + const target = sender() + const pending = previewTransfer({ ...REQUEST, previewId: 'p2' }, target.webContents) + + const total = PREVIEW_DELETE_LIMIT + 250 + for (let index = 0; index < total; index += 1) runs.current.push(change('delete', `old/${index}.bin`)) + runs.current.push(change('add', 'new/one.bin')) + runs.current.push({ type: 'exit', code: 0, signal: null, resumable: false, message: 'Done.' } as RsyncEvent) + runs.current.end() + + const result = await pending + expect(result).not.toHaveProperty('changes') + expect(result.deleteTotal).toBe(total) + expect(result.deletes).toHaveLength(PREVIEW_DELETE_LIMIT) + expect(result.changeTotal).toBe(total + 1) + expect(result.summary.delete).toBe(total) + expect(result.summary.add).toBe(1) + expect(result.ok).toBe(true) + }) + + /* + * The bug: closing the dialog only hid it. The dry run carried on to the end + * with no way to stop it, then resolved into a window nobody was looking at. + */ + it('cancels a running scan, and never reports a stopped scan as a usable result', async () => { + runs.current = fakeRun() + const target = sender() + const pending = previewTransfer({ ...REQUEST, previewId: 'p3' }, target.webContents) + + await until(() => target.sent.length > 0) + runs.current.push(change('delete', 'old/one.bin')) + + await until(() => cancelPreview('p3')) + const result = await pending + + expect(runs.current.killed).toBe(true) + expect(result.cancelled).toBe(true) + // `ok` gates the confirm button. A scan that was stopped has not + // established that anything is safe to delete. + expect(result.ok).toBe(false) + // The registration is gone, so a late Cancel cannot kill an unrelated run. + expect(cancelPreview('p3')).toBe(false) + }) + + /* + * The bug: quitting cancelled transfers but not previews, so closing the + * window mid-scan left an rsync and an ssh walking a remote tree with + * nothing left to report to. + */ + it('is stopped when the app quits', async () => { + runs.current = fakeRun() + const target = sender() + const pending = previewTransfer({ ...REQUEST, previewId: 'p4' }, target.webContents) + + await until(() => target.sent.length > 0) + cancelAll() + + const result = await pending + expect(runs.current.killed).toBe(true) + expect(result.cancelled).toBe(true) + }) +}) diff --git a/apps/desktop/electron/main/services/transfers.ts b/apps/desktop/electron/main/services/transfers.ts index 6c1163d..30f134b 100644 --- a/apps/desktop/electron/main/services/transfers.ts +++ b/apps/desktop/electron/main/services/transfers.ts @@ -5,12 +5,11 @@ import { parseRsyncCapabilities, planTransfer, runPlan, - runToCompletion, unknownCapabilities, type ExecutionPlan, type RsyncCapabilities, } from '@diskpush/rsync-core' -import { defaultRsyncOptions, summarizeChanges, type Change, type Endpoint, type RsyncOptions } from '@diskpush/schemas' +import { defaultRsyncOptions, summarizeChanges, type Endpoint, type RsyncOptions } from '@diskpush/schemas' import { execFile } from 'node:child_process' import { promisify } from 'node:util' import { IPC, type EndpointRef, type TransferOptions, type TransferRequest } from '../../shared/contract.js' @@ -129,33 +128,182 @@ async function buildPlan(request: TransferRequest, overrides: Partial + /** Capped at PREVIEW_DELETE_LIMIT; `deleteTotal` is the real number. */ deletes: string[] + deleteTotal: number + /** Every change rsync reported, deletions included. */ + changeTotal: number command: string control: string | null warnings: string[] ok: boolean message: string + /** True when the user stopped the scan rather than it finishing. */ + cancelled: boolean } -/** The dry run behind Preview Changes and behind every mirror. */ -export async function previewTransfer(request: TransferRequest): Promise { +type RunningPreview = { cancel: () => void; cancelled: boolean } +const previews = new Map() + +/** How often scan progress is pushed to the renderer, at most. */ +const PREVIEW_TICK_MS = 120 + +/** + * The dry run behind Preview Changes and behind every mirror. + * + * Three things it deliberately does not do, each of which it used to. + * + * It does not accumulate every change. A dry run of an ordinary source tree + * emits a few hundred thousand of them; keeping the array and returning it + * meant ~70MB crossing the IPC boundary by structured clone, for a field the + * renderer never read. Only the counts and the capped delete list survive. + * + * It does not run silently. Progress is streamed as it goes, so the dialog can + * show what is being compared instead of a spinner that is indistinguishable + * from a hang. + * + * It does not run unstoppably. The handle is registered under `previewId`, so + * Cancel kills the rsync process rather than only hiding the dialog and + * leaving the scan to finish into nothing. + */ +export async function previewTransfer( + request: TransferRequest & { previewId: string }, + sender: WebContents, +): Promise { const plan = await buildPlan(request, { dryRun: true }) - const result = await runToCompletion(plan) + + const summary = summarizeChanges([]) + const deletes: string[] = [] + let deleteTotal = 0 + let changeTotal = 0 + let checked = 0 + let total = 0 + let currentPath = '' + const startedAt = Date.now() + + const handle = runPlan(plan) + const entry: RunningPreview = { cancel: handle.cancel, cancelled: false } + previews.set(request.previewId, entry) + + let lastTick = 0 + const emit = (force: boolean) => { + const now = Date.now() + if (!force && now - lastTick < PREVIEW_TICK_MS) return + lastTick = now + if (sender.isDestroyed()) return + sender.send(IPC.eventPreview, { + previewId: request.previewId, + progress: { + checked, + total, + changes: changeTotal, + deletes: deleteTotal, + currentPath, + elapsedSeconds: Math.round((now - startedAt) / 1000), + } satisfies PreviewProgress, + }) + } + + // Sent before rsync has said anything, so the dialog starts with a scan it + // can see rather than with an empty panel it has to explain. + emit(true) + + let ok = false + let message = '' + + try { + for await (const event of handle.events) { + switch (event.type) { + case 'change': { + changeTotal += 1 + summary[event.change.action] += 1 + currentPath = event.change.path + if (event.change.action === 'delete') { + deleteTotal += 1 + if (deletes.length < PREVIEW_DELETE_LIMIT) deletes.push(event.change.path) + } + emit(false) + break + } + case 'progress': { + // rsync counts down: `to-chk=remaining/total`. + if (event.progress.filesTotal !== null) { + total = event.progress.filesTotal + checked = event.progress.filesTotal - (event.progress.filesRemaining ?? 0) + } + emit(false) + break + } + case 'exit': { + ok = event.code === 0 || event.code === 24 + message = event.message + break + } + default: + break + } + } + } finally { + previews.delete(request.previewId) + } + + emit(true) + return { - changes: result.changes, - summary: summarizeChanges(result.changes), - deletes: result.changes.filter((change) => change.action === 'delete').map((change) => change.path), + summary, + deletes, + deleteTotal, + changeTotal, command: plan.display, control: plan.controlDisplay ?? null, warnings: plan.warnings, - ok: result.ok, - message: result.message, + // A scan the user stopped is not a scan that failed, and it must never be + // reported as one: `ok` gates the confirm button, and a cancelled preview + // has not established that anything is safe to delete. + ok: entry.cancelled ? false : ok, + message: entry.cancelled ? 'Scan cancelled.' : message, + cancelled: entry.cancelled, } } +/** Stops a dry run that is still scanning. False when it already finished. */ +export function cancelPreview(previewId: string): boolean { + const entry = previews.get(previewId) + if (!entry) return false + entry.cancelled = true + entry.cancel() + return true +} + export type StartedJob = { jobId: string; command: string; control: string | null; warnings: string[] } export async function startTransfer(request: TransferRequest, sender: WebContents): Promise { @@ -275,4 +423,11 @@ export function hasActiveTransfer(): boolean { export function cancelAll(): void { for (const job of running.values()) job.cancel() + // Previews too. A dry run is read-only, but it is still an rsync and an ssh + // walking a remote tree, and quitting mid-scan used to leave both running + // with nothing left to report to. + for (const preview of previews.values()) { + preview.cancelled = true + preview.cancel() + } } diff --git a/apps/desktop/electron/preload/index.ts b/apps/desktop/electron/preload/index.ts index f643397..7869ac2 100644 --- a/apps/desktop/electron/preload/index.ts +++ b/apps/desktop/electron/preload/index.ts @@ -41,6 +41,7 @@ const api = { }, transfers: { preview: (request: unknown) => call(IPC.transfersPreview, request), + cancelPreview: (previewId: string) => call(IPC.transfersPreviewCancel, { previewId }), start: (request: unknown) => call(IPC.transfersStart, request), cancel: (jobId: string) => call(IPC.transfersCancel, { jobId }), list: (limit = 50) => call(IPC.transfersList, { limit }), @@ -87,6 +88,11 @@ const api = { ipcRenderer.on(IPC.eventFleet, wrapped) return () => ipcRenderer.off(IPC.eventFleet, wrapped) }, + onPreview(listener: (payload: { previewId: string; progress: unknown }) => void): () => void { + const wrapped = (_event: unknown, payload: { previewId: string; progress: unknown }) => listener(payload) + ipcRenderer.on(IPC.eventPreview, wrapped) + return () => ipcRenderer.off(IPC.eventPreview, wrapped) + }, }, } diff --git a/apps/desktop/electron/shared/contract.ts b/apps/desktop/electron/shared/contract.ts index 1088086..78f10eb 100644 --- a/apps/desktop/electron/shared/contract.ts +++ b/apps/desktop/electron/shared/contract.ts @@ -30,6 +30,7 @@ export const IPC = { fsCreateFileLocal: 'fs:create-file-local', transfersPreview: 'transfers:preview', + transfersPreviewCancel: 'transfers:preview-cancel', transfersStart: 'transfers:start', transfersCancel: 'transfers:cancel', transfersList: 'transfers:list', @@ -59,6 +60,14 @@ export const IPC = { eventTransfer: 'event:transfer', /** Main -> renderer, one channel carrying every fleet event. */ eventFleet: 'event:fleet', + /** + * Main -> renderer, live progress for a dry run that has not finished yet. + * + * A preview is a full scan of both sides and there is no upper bound on how + * long that takes. Without this the dialog could only sit on a spinner, so + * an eight-minute scan and a wedged one looked exactly alike. + */ + eventPreview: 'event:preview', } as const /** A path the renderer asked for. Length-capped, and never joined by the renderer. */ @@ -130,6 +139,18 @@ export type TransferRequest = z.infer export const JobIdSchema = z.string().uuid() +/** + * A preview, carrying the id the renderer will cancel it by. + * + * The id is chosen by the renderer rather than returned by the call, because + * the call does not return until the scan is over -- which is exactly the + * window in which it has to be cancellable. + */ +export const PreviewRequestSchema = TransferRequestSchema.extend({ + previewId: JobIdSchema, +}) +export type PreviewRequest = z.infer + /** * Saving the current pane pair and options as a named profile. * diff --git a/apps/desktop/src/app/page.tsx b/apps/desktop/src/app/page.tsx index 4e5cc0d..61c7d41 100644 --- a/apps/desktop/src/app/page.tsx +++ b/apps/desktop/src/app/page.tsx @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import Image from 'next/image' import { ArrowLeftRight, @@ -24,7 +24,15 @@ 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 SyncProfile, type TransferEvent } from '@/lib/api' +import { + api, + unwrap, + type Connection, + type PreviewProgress, + type PreviewResult, + type SyncProfile, + type TransferEvent, +} from '@/lib/api' import { withTrailingSlash } from '@/lib/format' /** A row in the header menu. Plain button, styled once. */ @@ -87,7 +95,18 @@ export default function Workspace() { const [mirror, setMirror] = useState(false) const [trust, setTrust] = useState(false) const [preview, setPreview] = useState(null) + const [previewProgress, setPreviewProgress] = useState(null) const [previewOpen, setPreviewOpen] = useState(false) + /** + * The scan the dialog is currently showing. + * + * A preview cannot be identified by "the one that is running", because + * stopping one and starting another leaves both in flight: the first call + * still resolves, and without this it would paint its delete list over the + * second one's route. Confirming that would mirror a pair the user never + * saw. Every result and every progress event is matched against this id. + */ + const previewIdRef = useRef(null) const [job, setJob] = useState(null) const [error, setError] = useState(null) const [showConnection, setShowConnection] = useState(false) @@ -183,6 +202,15 @@ export default function Workspace() { return bridge.events.onTransfer(({ jobId, event }) => setJob((current) => reduceJob(current, jobId, event))) }, []) + useEffect(() => { + const bridge = api() + if (!bridge) return + return bridge.events.onPreview(({ previewId, progress }) => { + if (previewId !== previewIdRef.current) return + setPreviewProgress(progress) + }) + }, []) + const source = direction === 'ltr' ? left : right const destination = direction === 'ltr' ? right : left const allConnections = useMemo(() => [...saved, ...sshConfig], [saved, sshConfig]) @@ -200,13 +228,43 @@ export default function Workspace() { [source, destination, mirror], ) + /** + * Closes the dialog and stops the scan behind it. + * + * Closing used to only hide the dialog. The rsync dry run carried on to the + * end -- minutes of a remote tree walk nobody could see or interrupt -- and + * then resolved into a dialog that was no longer open. + */ + const closePreview = useCallback(() => { + const previewId = previewIdRef.current + previewIdRef.current = null + setPreviewOpen(false) + setPreviewProgress(null) + if (previewId) void api()?.transfers.cancelPreview(previewId) + }, []) + const runPreview = useCallback(async () => { + const previewId = crypto.randomUUID() + // Supersedes any scan already running, so pressing Preview twice does not + // leave two rsync processes walking the same trees. + const superseded = previewIdRef.current + if (superseded) void api()?.transfers.cancelPreview(superseded) + + previewIdRef.current = previewId setError(null) setPreview(null) + setPreviewProgress(null) setPreviewOpen(true) try { - setPreview(await unwrap(api()?.transfers.preview(request))) + const result = await unwrap(api()?.transfers.preview({ ...request, previewId })) + if (previewIdRef.current !== previewId) return + // A scan the user stopped is not a result. Showing it would offer a + // confirm button for a delete list that was never finished. + if (result.cancelled) return + setPreview(result) } catch (caught) { + if (previewIdRef.current !== previewId) return + previewIdRef.current = null setPreviewOpen(false) setError(caught instanceof Error ? caught.message : String(caught)) } @@ -215,7 +273,9 @@ export default function Workspace() { const start = useCallback( async (deletesConfirmed: boolean) => { setError(null) + previewIdRef.current = null setPreviewOpen(false) + setPreviewProgress(null) try { const started = await unwrap(api()?.transfers.start({ ...request, deletesConfirmed })) setJob({ @@ -575,11 +635,13 @@ export default function Workspace() { setPreviewOpen(false)} + onCancel={closePreview} + onStopScan={closePreview} onConfirm={() => void start(true)} /> diff --git a/apps/desktop/src/components/transfer-panel.tsx b/apps/desktop/src/components/transfer-panel.tsx index 6b42459..f10fc9e 100644 --- a/apps/desktop/src/components/transfer-panel.tsx +++ b/apps/desktop/src/components/transfer-panel.tsx @@ -1,8 +1,8 @@ 'use client' -import { useRef } from 'react' +import { useEffect, useRef } from 'react' import { ArrowRight, ChevronRight, CircleCheck, Trash2, TriangleAlert } from 'lucide-react' -import type { PreviewResult } from '@/lib/api' +import type { PreviewProgress, PreviewResult } from '@/lib/api' import { formatBytes, formatDuration, formatRate } from '@/lib/format' import { Button } from '@/components/ui/button' import { Checkbox } from '@/components/ui/checkbox' @@ -24,33 +24,138 @@ export type ActiveJob = { message: string } +/** + * What the scan panel shows before rsync has said anything. + * + * A zeroed progress rather than an absent one, so the panel has the same shape + * from the first frame and does not rearrange itself once the first event + * lands. + */ +const EMPTY_PROGRESS: PreviewProgress = { + checked: 0, + total: 0, + changes: 0, + deletes: 0, + currentPath: '', + elapsedSeconds: 0, +} + +/** + * Live progress while both sides are being compared. + * + * This used to be a spinner and the words "Scanning both sides...", which is + * all a person saw for however long a full scan of two trees takes -- minutes + * over a WAN. A scan that is working and a scan that has wedged looked + * identical, so every slow preview read as a hang. It now shows rsync's own + * counters, so the panel moves whenever the scan does. + * + * `total` grows during the run, because rsync builds its file list + * incrementally. The bar is therefore explicitly an estimate, and the counts + * beside it are the honest numbers. + */ +function ScanProgress({ progress, onCancel }: { progress: PreviewProgress; onCancel: () => void }) { + const { checked, total, changes, deletes, currentPath, elapsedSeconds } = progress + const percent = total > 0 ? Math.min(100, Math.round((checked / total) * 100)) : 0 + + return ( +
+
+ + Comparing both sides + + {formatDuration(elapsedSeconds)} + +
+ + + +
+ + {checked.toLocaleString()} + {total > 0 ? <> of about {total.toLocaleString()} : null}{' '} + compared + + + {changes.toLocaleString()} to change + + {deletes > 0 ? ( + + {deletes.toLocaleString()} to delete + + ) : null} +
+ + {/* + The path currently being compared. Without it a stalled scan and a slow + one still look the same, because the counters can sit still for a long + time on one large directory. + */} +

{currentPath || 'building the file list…'}

+ + {/* + Stopping is a real stop. Closing the dialog used to hide it and leave + the dry run to finish into a window nobody was looking at, which is how + a mistyped path cost you a full scan you could not interrupt. + */} + +
+ ) +} + /** * The delete preview. * * Every proposed deletion is listed rather than summarised: "87 files" is not - * something anyone can consent to. The confirm button says what it does. + * something anyone can consent to. Past PREVIEW_DELETE_LIMIT the enumeration + * stops and says so, because a first mirror into an empty destination proposes + * hundreds of thousands and a DOM node each is what froze the window. The + * count on the confirm button is always the true one. */ export function MirrorPreviewDialog({ preview, + progress, open, route, trust, onTrustChange, onCancel, + onStopScan, onConfirm, }: { preview: PreviewResult | null + progress: PreviewProgress | null open: boolean route: string trust: boolean onTrustChange: (value: boolean) => void onCancel: () => void + onStopScan: () => void onConfirm: () => void }) { const deletes = preview?.deletes ?? [] + const deleteTotal = preview?.deleteTotal ?? 0 + const hidden = Math.max(0, deleteTotal - deletes.length) const summary = preview?.summary const cancelRef = useRef(null) + /* + * Focus lands on Cancel the moment the result does. + * + * `initialFocus` alone stopped being enough once the footer waits for the + * scan: at open time there is no Cancel button to focus, so when the delete + * list finally appeared the focus was still on the dialog and the next Enter + * could reach the confirm. This dialog's whole job is to make an + * irreversible delete deliberate. + */ + useEffect(() => { + if (preview) cancelRef.current?.focus() + }, [preview]) + return ( (next ? undefined : onCancel())}> {!preview ? ( -
- - Scanning both sides… -
+ ) : !preview.ok ? (

{preview.message}

) : ( @@ -88,7 +190,7 @@ export function MirrorPreviewDialog({ ['Add', summary?.add ?? 0, 'text-ok'], ['Update', summary?.update ?? 0, 'text-primary'], ['Unchanged', summary?.unchanged ?? 0, 'text-muted-foreground'], - ['Delete', deletes.length, 'text-destructive'], + ['Delete', deleteTotal, 'text-destructive'], ] as const ).map(([label, value, tone]) => (
@@ -99,12 +201,12 @@ export function MirrorPreviewDialog({
- {deletes.length > 0 ? ( + {deleteTotal > 0 ? (
- {deletes.length.toLocaleString()} file{deletes.length === 1 ? '' : 's'} + {deleteTotal.toLocaleString()} file{deleteTotal === 1 ? '' : 's'} {' '} at the destination will be deleted. This cannot be undone. @@ -116,22 +218,36 @@ export function MirrorPreviewDialog({ )}
- {deletes.length > 0 ? ( + {deleteTotal > 0 ? (
Files to be deleted
{/* max-, not a fixed height: two doomed files used to sit at the top of a 210px well of empty space. */} - - {deletes.map((path) => ( -
- - {path} -
- ))} -
+ {/* + The scroll box needs a definite height, which `max-h` on the + ScrollArea root alone never gave it: its viewport is + `size-full`, so it grew to fit and the rows printed straight + over the disclosure below. Every other ScrollArea in the app + is `min-h-0 flex-1` inside a flex column, and so is this one. + */} +
+ + {deletes.map((path) => ( +
+ + {path} +
+ ))} + {hidden > 0 ? ( +
+ …and {hidden.toLocaleString()} more, not listed +
+ ) : null} +
+
) : null} @@ -159,30 +275,38 @@ export function MirrorPreviewDialog({ )}
- - -
- {/* - Focus opens on Cancel, not on the confirm and not on the trust - checkbox it used to land on. This dialog's whole job is to make - an irreversible delete deliberate, so a stray Enter or Space has - to hit the harmless control. - */} - - -
-
+ {/* + No confirm controls while there is nothing to confirm. The footer used + to render throughout the scan, so a full-strength Mirror button sat + under the spinner offering to run a mirror whose delete list did not + exist yet -- inert, which reads as a control that ignores you. + */} + {preview ? ( + + +
+ {/* + Focus opens on Cancel, not on the confirm and not on the trust + checkbox it used to land on. This dialog's whole job is to make + an irreversible delete deliberate, so a stray Enter or Space has + to hit the harmless control. + */} + + +
+
+ ) : null}
) diff --git a/apps/desktop/src/lib/api.ts b/apps/desktop/src/lib/api.ts index 514d3f8..5135458 100644 --- a/apps/desktop/src/lib/api.ts +++ b/apps/desktop/src/lib/api.ts @@ -55,14 +55,38 @@ export type Change = { } export type PreviewResult = { - changes: Change[] summary: Record + /** + * Capped by the main process. `deleteTotal` is the true number and is what + * the confirm button counts, so a truncated list never understates what a + * mirror is about to remove. + */ deletes: string[] + deleteTotal: number + changeTotal: number command: string control: string | null warnings: string[] ok: boolean message: string + /** The user stopped the scan. Not a failure, and not a result to act on. */ + cancelled: boolean +} + +/** + * Live progress for a dry run that is still going. + * + * `total` comes from rsync's own `to-chk` counter and grows while the file + * list is still being built, so it is shown as a moving count rather than as a + * deadline. + */ +export type PreviewProgress = { + checked: number + total: number + changes: number + deletes: number + currentPath: string + elapsedSeconds: number } /** A saved source/destination pair with its options. Runnable from the CLI too. */ @@ -234,6 +258,7 @@ type Api = { } transfers: { preview(request: unknown): Promise> + cancelPreview(previewId: string): Promise> start(request: unknown): Promise> cancel(jobId: string): Promise> list(limit?: number): Promise> @@ -269,6 +294,7 @@ type Api = { events: { onTransfer(listener: (payload: { jobId: string; event: TransferEvent }) => void): () => void onFleet(listener: (payload: { runId: string; event: FleetEvent }) => void): () => void + onPreview(listener: (payload: { previewId: string; progress: PreviewProgress }) => void): () => void } }