Skip to content

Commit d10d9cc

Browse files
committed
Fix copy progress: per-file counter and stale scan prevention
- Add `on_file_complete` callback to `copy_single_path` and recursive helpers. File counter now increments per individual file during directory copies, not per top-level item. Progress bar shows accurate "42/200000 files". - Remove stale `previewId` adoption in `TransferDialog.isOurScanEvent()`. Events from orphaned previous scans are now rejected instead of adopted. After IPC returns, checks `checkScanPreviewStatus` for the race where scan completes before the ID is known.
1 parent 0e7f072 commit d10d9cc

3 files changed

Lines changed: 86 additions & 18 deletions

File tree

apps/desktop/src-tauri/src/file_system/write_operations/volume_copy.rs

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ use std::cell::Cell;
1919
use std::ops::ControlFlow;
2020
use std::path::{Path, PathBuf};
2121
use std::sync::Arc;
22-
use std::sync::atomic::{AtomicU8, AtomicU64, Ordering};
22+
use std::sync::atomic::{AtomicU8, AtomicU64, AtomicUsize, Ordering};
2323
use std::time::{Duration, Instant};
2424
use uuid::Uuid;
2525

@@ -379,6 +379,9 @@ fn copy_volumes_with_progress(
379379
}
380380

381381
// Phase 3: Copy files with progress
382+
// files_done tracks individual files (updated by on_file_complete callback from recursive copy).
383+
// total_files is the recursive count from the scan.
384+
let files_done_atomic = AtomicUsize::new(0);
382385
let mut files_done = 0;
383386
let mut bytes_done = 0u64;
384387
let mut last_progress_time = Instant::now();
@@ -510,6 +513,9 @@ fn copy_volumes_with_progress(
510513
let delta = file_bytes_done.saturating_sub(prev);
511514
let current_total = atomic_bytes_done.fetch_add(delta, Ordering::Relaxed) + delta;
512515

516+
// Read current files_done from the atomic (updated by on_file_complete)
517+
let current_files_done = files_done_atomic.load(Ordering::Relaxed);
518+
513519
// Throttled progress emission
514520
let last = last_progress_cell.get();
515521
if last.elapsed() >= progress_interval {
@@ -521,7 +527,7 @@ fn copy_volumes_with_progress(
521527
operation_type: WriteOperationType::Copy,
522528
phase: WriteOperationPhase::Copying,
523529
current_file: file_name_for_cb.clone(),
524-
files_done,
530+
files_done: current_files_done,
525531
files_total: total_files,
526532
bytes_done: current_total,
527533
bytes_total: total_bytes,
@@ -531,7 +537,7 @@ fn copy_volumes_with_progress(
531537
operation_id,
532538
WriteOperationPhase::Copying,
533539
file_name_for_cb.clone(),
534-
files_done,
540+
current_files_done,
535541
total_files,
536542
current_total,
537543
total_bytes,
@@ -541,6 +547,11 @@ fn copy_volumes_with_progress(
541547
ControlFlow::Continue(())
542548
};
543549

550+
// Build the on_file_complete callback that increments the atomic file counter
551+
let on_file_complete = || {
552+
files_done_atomic.fetch_add(1, Ordering::Relaxed);
553+
};
554+
544555
// Remember the destination path before copying (for partial-file cleanup)
545556
last_dest_path = Some(dest_item_path.clone());
546557

@@ -551,13 +562,15 @@ fn copy_volumes_with_progress(
551562
&dest_item_path,
552563
state,
553564
&on_file_progress,
565+
&on_file_complete,
554566
) {
555567
Ok(bytes_copied) => {
556568
// Copy succeeded — record destination path for potential rollback
557569
copied_paths.push(dest_item_path);
558570
last_dest_path = None;
559571

560-
files_done += 1;
572+
// Sync files_done from the atomic (updated by on_file_complete during recursive copy)
573+
files_done = files_done_atomic.load(Ordering::Relaxed);
561574
// Sync bytes_done from the atomic (the callback may have updated it mid-file)
562575
bytes_done = atomic_bytes_done.load(Ordering::Relaxed);
563576
// If the volume didn't call the progress callback (default impl), add bytes_copied
@@ -1004,6 +1017,7 @@ pub async fn move_between_volumes(
10041017
&dest_item,
10051018
&state,
10061019
&no_progress,
1020+
&|| {},
10071021
)
10081022
.map_err(|e| map_volume_error(&source_path.display().to_string(), e))?;
10091023

apps/desktop/src-tauri/src/file_system/write_operations/volume_strategy.rs

Lines changed: 48 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ pub(super) fn copy_single_path(
4040
dest_path: &Path,
4141
state: &Arc<WriteOperationState>,
4242
on_file_progress: &dyn Fn(u64, u64) -> ControlFlow<()>,
43+
on_file_complete: &dyn Fn(),
4344
) -> Result<u64, VolumeError> {
4445
// Check cancellation
4546
if super::state::is_cancelled(&state.intent) {
@@ -90,9 +91,18 @@ pub(super) fn copy_single_path(
9091
// For directories, walk the tree ourselves so we can check cancellation between files.
9192
// import_from_local(dir) would import everything in one shot with no cancellation.
9293
if local_source.is_dir() {
93-
import_directory_cancellable(&local_source, dest_path, dest_volume, state, on_file_progress)
94+
import_directory_cancellable(
95+
&local_source,
96+
dest_path,
97+
dest_volume,
98+
state,
99+
on_file_progress,
100+
on_file_complete,
101+
)
94102
} else {
95-
dest_volume.import_from_local_with_progress(&local_source, dest_path, on_file_progress)
103+
let bytes = dest_volume.import_from_local_with_progress(&local_source, dest_path, on_file_progress)?;
104+
on_file_complete();
105+
Ok(bytes)
96106
}
97107
} else if !source_is_local && dest_is_local {
98108
// Source is not local, dest is local (like SMB/MTP → Local)
@@ -104,20 +114,29 @@ pub(super) fn copy_single_path(
104114
// For directories, walk the tree ourselves for cancellation support.
105115
let is_dir = source_volume.is_directory(source_path).unwrap_or(false);
106116
if is_dir {
107-
export_directory_cancellable(source_path, &local_dest, source_volume, state, on_file_progress)
117+
export_directory_cancellable(
118+
source_path,
119+
&local_dest,
120+
source_volume,
121+
state,
122+
on_file_progress,
123+
on_file_complete,
124+
)
108125
} else {
109-
source_volume.export_to_local_with_progress(source_path, &local_dest, on_file_progress)
126+
let bytes = source_volume.export_to_local_with_progress(source_path, &local_dest, on_file_progress)?;
127+
on_file_complete();
128+
Ok(bytes)
110129
}
111130
} else {
112131
// Both are local, use export which resolves paths internally
113-
// Note: export_to_local takes a path relative to the volume root for source,
114-
// and an absolute local path for destination
115132
let local_dest = if dest_path.is_absolute() {
116133
dest_path.to_path_buf()
117134
} else {
118135
dest_volume.root().join(dest_path)
119136
};
120-
source_volume.export_to_local(source_path, &local_dest)
137+
let bytes = source_volume.export_to_local(source_path, &local_dest)?;
138+
on_file_complete();
139+
Ok(bytes)
121140
}
122141
}
123142

@@ -130,6 +149,7 @@ fn import_directory_cancellable(
130149
dest_volume: &Arc<dyn Volume>,
131150
state: &Arc<WriteOperationState>,
132151
on_file_progress: &dyn Fn(u64, u64) -> ControlFlow<()>,
152+
on_file_complete: &dyn Fn(),
133153
) -> Result<u64, VolumeError> {
134154
// Create the directory on the destination
135155
dest_volume.create_directory(dest_path)?;
@@ -149,10 +169,17 @@ fn import_directory_cancellable(
149169
let child_dest = dest_path.join(&child_name);
150170

151171
if child_local.is_dir() {
152-
total_bytes +=
153-
import_directory_cancellable(&child_local, &child_dest, dest_volume, state, on_file_progress)?;
172+
total_bytes += import_directory_cancellable(
173+
&child_local,
174+
&child_dest,
175+
dest_volume,
176+
state,
177+
on_file_progress,
178+
on_file_complete,
179+
)?;
154180
} else {
155181
total_bytes += dest_volume.import_from_local_with_progress(&child_local, &child_dest, on_file_progress)?;
182+
on_file_complete();
156183
}
157184
}
158185

@@ -167,6 +194,7 @@ fn export_directory_cancellable(
167194
source_volume: &Arc<dyn Volume>,
168195
state: &Arc<WriteOperationState>,
169196
on_file_progress: &dyn Fn(u64, u64) -> ControlFlow<()>,
197+
on_file_complete: &dyn Fn(),
170198
) -> Result<u64, VolumeError> {
171199
// Create the local directory
172200
std::fs::create_dir_all(local_dest).map_err(|e| VolumeError::IoError(e.to_string()))?;
@@ -185,10 +213,17 @@ fn export_directory_cancellable(
185213
let child_local = local_dest.join(&entry.name);
186214

187215
if entry.is_directory {
188-
total_bytes +=
189-
export_directory_cancellable(child_source, &child_local, source_volume, state, on_file_progress)?;
216+
total_bytes += export_directory_cancellable(
217+
child_source,
218+
&child_local,
219+
source_volume,
220+
state,
221+
on_file_progress,
222+
on_file_complete,
223+
)?;
190224
} else {
191225
total_bytes += source_volume.export_to_local_with_progress(child_source, &child_local, on_file_progress)?;
226+
on_file_complete();
192227
}
193228
}
194229

@@ -291,6 +326,7 @@ mod tests {
291326
Path::new("dest.txt"),
292327
&state,
293328
&no_progress,
329+
&|| {},
294330
)
295331
.unwrap();
296332

@@ -332,6 +368,7 @@ mod tests {
332368
Path::new("dest.txt"),
333369
&state,
334370
&no_progress,
371+
&|| {},
335372
);
336373

337374
assert!(result.is_err());

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

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
formatBytes,
66
startScanPreview,
77
cancelScanPreview,
8+
checkScanPreviewStatus,
89
onScanPreviewProgress,
910
onScanPreviewComplete,
1011
onScanPreviewError,
@@ -254,9 +255,9 @@
254255
255256
/** Accepts the event if it belongs to our scan, filtering stale events from previous scans. */
256257
function isOurScanEvent(eventPreviewId: string): boolean {
257-
// previewId may still be null if the scan completes before startScanPreview returns.
258-
// In that case, adopt the first event's previewId (it's from the scan we just started).
259-
if (!previewId) previewId = eventPreviewId
258+
// Don't accept events until we know our previewId from the IPC return.
259+
// This prevents adopting stale events from previous orphaned scans.
260+
if (!previewId) return false
260261
return eventPreviewId === previewId
261262
}
262263
@@ -302,6 +303,22 @@
302303
const progressIntervalMs = getSetting('fileOperations.progressUpdateInterval')
303304
const result = await startScanPreview(sourcePaths, sortColumn, sortOrder, progressIntervalMs, sourceVolumeId)
304305
previewId = result.previewId
306+
307+
// Check if the scan already completed while we were awaiting the IPC return.
308+
// Events that arrived before previewId was set were dropped (isOurScanEvent returned false),
309+
// so we need to check the backend's cached result.
310+
if (isScanning) {
311+
const alreadyComplete = await checkScanPreviewStatus(previewId)
312+
if (alreadyComplete) {
313+
// The scan finished before we could listen. Re-fetch the result by triggering
314+
// a fresh scan status check — the backend will re-emit the complete event.
315+
// For now, mark as complete with whatever stats we have (they'll be updated
316+
// when the copy starts and reads the cached scan result).
317+
isScanning = false
318+
scanComplete = true
319+
void checkConflicts()
320+
}
321+
}
305322
}
306323
307324
onMount(async () => {

0 commit comments

Comments
 (0)