Skip to content

Commit dd06d68

Browse files
committed
Transfer: Fix stuck "Scanning" dialog race
The progress dialog could get stuck in "Scanning 0 files, 0 dirs" forever when the user confirmed (or Playwright clicked) before `startScanPreview` IPC returned. In that case, `previewId` was null when `TransferProgressDialog` mounted. The dialog's null-recovery strategy was to adopt the ID from the first scan event — but if those events fired between listener setup and listener registration, they were lost, `previewId` stayed null, the status check was skipped, and the dialog waited forever for events that never came. - `TransferDialog.handleConfirm` now awaits the `startScan()` promise before calling `onConfirm`, guaranteeing `previewId` is set (or scan errored) - Double-confirm guard added since `handleConfirm` is now async - `TransferProgressDialog` no longer tries to adopt `previewId` from events. `isOurScanEvent` simplified, `effectivePreviewId` removed. The null case now logs an error and falls through to `startOperation` Consistent with the dialog's own `filterEvent` pattern for write operations: subscribe first, then dispatch the command, then match events by known ID.
1 parent 98d4cfc commit dd06d68

2 files changed

Lines changed: 47 additions & 38 deletions

File tree

apps/desktop/src/lib/file-operations/transfer/TransferDialog.svelte

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,11 @@
114114
let isScanning = $state(false)
115115
let scanComplete = $state(false)
116116
let unlisteners: UnlistenFn[] = []
117+
// Promise that resolves once startScanPreview IPC has returned and previewId is set.
118+
// handleConfirm awaits this to guarantee previewId is non-null when passed to
119+
// TransferProgressDialog — otherwise a fast confirm races with IPC and leaves the
120+
// progress dialog stuck in "Scanning 0 files" forever.
121+
let scanStarted: Promise<void> = Promise.resolve()
117122
118123
// Whether the user confirmed (so we don't cancel the scan on destroy)
119124
let confirmed = false
@@ -332,13 +337,14 @@
332337
333338
// Volume space is loaded by the $effect watching selectedVolumeId
334339
335-
// Start scanning files immediately
336-
void startScan()
340+
// Start scanning files immediately. Track the promise so handleConfirm can
341+
// await it — this ensures previewId is set before onConfirm fires.
342+
scanStarted = startScan()
337343
338344
// Auto-confirm if MCP requested it (after a tick so the dialog is fully initialized)
339345
if (autoConfirm) {
340346
await tick()
341-
handleConfirm()
347+
await handleConfirm()
342348
}
343349
})
344350
@@ -352,10 +358,14 @@
352358
cleanup()
353359
})
354360
355-
function handleConfirm() {
356-
if (pathError) return
361+
async function handleConfirm() {
362+
if (pathError || confirmed) return
357363
confirmed = true
358-
// Pass the previewId, conflict policy, (possibly toggled) operation type, and whether scan is still running
364+
// Wait for startScanPreview IPC to return so previewId is set. Without this,
365+
// a fast confirm (auto-confirm, Playwright test, rapid Enter keypress) races
366+
// with the IPC and leaves the progress dialog with a null previewId that it
367+
// cannot recover from once scan events have already been emitted.
368+
await scanStarted
359369
onConfirm(editedPath, selectedVolumeId, previewId, conflictPolicy, activeOperationType, isScanning)
360370
}
361371
@@ -370,15 +380,15 @@
370380
371381
function handleKeydown(event: KeyboardEvent) {
372382
if (event.key === 'Enter') {
373-
handleConfirm()
383+
void handleConfirm()
374384
}
375385
}
376386
377387
function handleInputKeydown(event: KeyboardEvent) {
378388
if (event.key === 'Enter') {
379389
event.preventDefault()
380390
event.stopPropagation()
381-
handleConfirm()
391+
void handleConfirm()
382392
}
383393
}
384394
</script>

apps/desktop/src/lib/file-operations/transfer/TransferProgressDialog.svelte

Lines changed: 29 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -144,8 +144,6 @@
144144
let scanDirsFound = $state(0)
145145
let scanBytesFound = $state(0)
146146
let scanUnlisteners: UnlistenFn[] = []
147-
/** Mutable copy of previewId — may be adopted from the first scan event if the prop was null. */
148-
let effectivePreviewId: string | null = previewId
149147
150148
// Operation state
151149
let operationId = $state<string | null>(null)
@@ -406,12 +404,12 @@
406404
const maxConflictsToShow = getSetting('fileOperations.maxConflictsToShow')
407405
408406
if (operationType === 'trash') {
409-
return trashFiles(sourcePaths, itemSizes, { progressIntervalMs, previewId: effectivePreviewId })
407+
return trashFiles(sourcePaths, itemSizes, { progressIntervalMs, previewId })
410408
}
411409
if (operationType === 'delete') {
412410
return deleteFiles(
413411
sourcePaths,
414-
{ progressIntervalMs, sortColumn, sortOrder, previewId: effectivePreviewId },
412+
{ progressIntervalMs, sortColumn, sortOrder, previewId },
415413
sourceVolumeId,
416414
)
417415
}
@@ -427,7 +425,7 @@
427425
conflictResolution,
428426
progressIntervalMs,
429427
maxConflictsToShow,
430-
previewId: effectivePreviewId,
428+
previewId,
431429
},
432430
)
433431
}
@@ -438,7 +436,7 @@
438436
maxConflictsToShow,
439437
sortColumn,
440438
sortOrder,
441-
previewId: effectivePreviewId,
439+
previewId,
442440
})
443441
}
444442
// Copy: always use copyBetweenVolumes — the backend handles local-to-local optimization
@@ -451,7 +449,7 @@
451449
conflictResolution,
452450
progressIntervalMs,
453451
maxConflictsToShow,
454-
previewId: effectivePreviewId,
452+
previewId,
455453
},
456454
)
457455
}
@@ -517,9 +515,9 @@
517515
518516
async function handleCancel(rollback: boolean) {
519517
// If still waiting for scan preview, cancel the scan and close
520-
if (waitingForScan && effectivePreviewId) {
521-
log.info('Cancelling scan preview during wait: previewId={previewId}', { previewId: effectivePreviewId })
522-
void cancelScanPreview(effectivePreviewId)
518+
if (waitingForScan && previewId) {
519+
log.info('Cancelling scan preview during wait: previewId={previewId}', { previewId })
520+
void cancelScanPreview(previewId)
523521
waitingForScan = false
524522
cleanupScanListeners()
525523
onCancelled(0)
@@ -622,21 +620,26 @@
622620
scanUnlisteners = []
623621
}
624622
625-
/** Returns true if the event belongs to our scan preview, adopting the ID if we don't have one yet. */
623+
/** Returns true if the event belongs to our scan preview. */
626624
function isOurScanEvent(eventPreviewId: string): boolean {
627-
if (!effectivePreviewId) effectivePreviewId = eventPreviewId
628-
return eventPreviewId === effectivePreviewId
625+
return eventPreviewId === previewId
629626
}
630627
631628
/**
632629
* Waits for the scan preview to complete, then starts the write operation.
633630
*
634631
* Subscribes to scan events BEFORE checking status to avoid the race where
635632
* the scan completes between the status check and listener registration.
636-
* If previewId is null (TransferDialog confirmed before startScanPreview IPC
637-
* returned), adopts the first event's previewId.
633+
*
634+
* Precondition: previewId must be non-null (guaranteed by TransferDialog,
635+
* which awaits startScanPreview IPC before calling onConfirm).
638636
*/
639637
async function waitForScanThenStart() {
638+
if (!previewId) {
639+
log.error('waitForScanThenStart called with null previewId — TransferDialog invariant violated')
640+
void startOperation()
641+
return
642+
}
640643
// Subscribe to events FIRST to avoid missing fast completions.
641644
// Same pattern as TransferDialog.startScan().
642645
scanUnlisteners.push(
@@ -690,21 +693,17 @@
690693
)
691694
692695
// NOW check if already complete (covers race where scan finished during subscription setup)
693-
if (effectivePreviewId) {
694-
const alreadyComplete = await checkScanPreviewStatus(effectivePreviewId)
695-
if (alreadyComplete) {
696-
log.info('Scan preview already complete for previewId={previewId}, starting operation immediately', {
697-
previewId: effectivePreviewId,
698-
})
699-
cleanupScanListeners()
700-
void startOperation()
701-
return
702-
}
696+
const alreadyComplete = await checkScanPreviewStatus(previewId)
697+
if (alreadyComplete) {
698+
log.info('Scan preview already complete for previewId={previewId}, starting operation immediately', {
699+
previewId,
700+
})
701+
cleanupScanListeners()
702+
void startOperation()
703+
return
703704
}
704705
705-
log.info('Scan preview still running for previewId={previewId}, subscribing to events', {
706-
previewId: effectivePreviewId,
707-
})
706+
log.info('Scan preview still running for previewId={previewId}, subscribing to events', { previewId })
708707
waitingForScan = true
709708
}
710709
@@ -719,8 +718,8 @@
719718
onDestroy(() => {
720719
destroyed = true
721720
// Cancel scan preview if still waiting for it
722-
if (waitingForScan && effectivePreviewId) {
723-
void cancelScanPreview(effectivePreviewId)
721+
if (waitingForScan && previewId) {
722+
void cancelScanPreview(previewId)
724723
}
725724
cleanupScanListeners()
726725
if (operationId && !operationSettled) {

0 commit comments

Comments
 (0)