Skip to content

Commit 1ae5c19

Browse files
committed
MTP→SMB copy: kill 2-min stall, speed up scan
- Fix O(n²) in `MtpVolume::scan_for_copy_batch`: build a `HashMap<name, Entry>` per parent listing. 15k photos in one MTP dir went from ~10 s of name-find sweeps to ~50 ms (visible as the "Verifying before copy..." dialog stuck at 0/0/0). - Stop discarding per-path data after the volume scan preview. `CachedScanResult` now carries `BatchScanResult::per_path`; `copy_volumes_with_progress` seeds its `source_hints` from the cache instead of looping `is_directory` per source. Each MTP `is_directory` lists the parent dir, so 15k sources = 15k sequential parent listings, a ~2-minute stall between scan-complete and copy-start (visible as the dialog stuck on "Scanning" with the final tallies pre-populated and no progress).
1 parent 09b3f41 commit 1ae5c19

5 files changed

Lines changed: 53 additions & 20 deletions

File tree

apps/desktop/src-tauri/src/file_system/volume/mtp.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -509,11 +509,19 @@ impl Volume for MtpVolume {
509509
let parent_str = parent.to_string_lossy();
510510
let entries = self.list_directory(Path::new(parent_str.as_ref()), None).await?;
511511

512+
// Index entries by name so each child lookup is O(1). A naive
513+
// `entries.iter().find(...)` per child is O(n) and the outer
514+
// loop is also O(n), so 15k photos in /DCIM/Camera turned a
515+
// single parent listing into ~225M string comparisons (~10 s
516+
// stall in the scan preview).
517+
let entries_by_name: std::collections::HashMap<&str, &FileEntry> =
518+
entries.iter().map(|e| (e.name.as_str(), e)).collect();
519+
512520
for child_path in children {
513521
let mtp_path = self.to_mtp_path(child_path);
514522
let name = Path::new(&mtp_path).file_name().and_then(|n| n.to_str()).unwrap_or("");
515523

516-
if let Some(entry) = entries.iter().find(|e| e.name == name) {
524+
if let Some(entry) = entries_by_name.get(name).copied() {
517525
if entry.is_directory {
518526
let scan = self.scan_for_copy(child_path).await?;
519527
aggregate.file_count += scan.file_count;

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,7 @@ pub(super) fn take_cached_scan_result(preview_id: &str) -> Option<ScanResult> {
200200
dirs: cached.dirs,
201201
file_count: cached.file_count,
202202
total_bytes: cached.total_bytes,
203+
per_path: cached.per_path,
203204
})
204205
} else {
205206
None
@@ -397,6 +398,7 @@ fn scan_sources_internal(
397398
files,
398399
dirs,
399400
total_bytes,
401+
per_path: Vec::new(),
400402
})
401403
}
402404

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

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ use super::types::{
1818
ScanPreviewStartResult,
1919
};
2020
use crate::file_system::listing::{SortColumn, SortOrder};
21-
use crate::file_system::volume::CopyScanResult;
2221
use crate::file_system::volume::Volume;
2322

2423
/// Starts a scan preview for the Copy dialog.
@@ -175,6 +174,7 @@ fn run_scan_preview(
175174
dirs,
176175
file_count,
177176
total_bytes,
177+
per_path: Vec::new(),
178178
},
179179
);
180180
}
@@ -211,33 +211,35 @@ async fn run_volume_scan_preview(
211211
) {
212212
use tauri::Emitter;
213213

214-
let result: Result<CopyScanResult, String> = async {
214+
let result: Result<crate::file_system::volume::BatchScanResult, String> = async {
215215
if state.cancelled.load(Ordering::Relaxed) {
216216
return Err("Cancelled".to_string());
217217
}
218218

219219
volume
220220
.scan_for_copy_batch(&sources)
221221
.await
222-
.map(|b| b.aggregate)
223222
.map_err(|e| format!("Scan failed: {}", e))
224223
}
225224
.await;
226225

227226
// Extract stats from the result for the completion event
228227
let (total_files, total_dirs, total_bytes) = match &result {
229-
Ok(scan) => (scan.file_count, scan.dir_count, scan.total_bytes),
228+
Ok(batch) => (
229+
batch.aggregate.file_count,
230+
batch.aggregate.dir_count,
231+
batch.aggregate.total_bytes,
232+
),
230233
Err(_) => (0, 0, 0),
231234
};
232-
let result = result.map(|_| ());
233235

234236
// Clean up state
235237
if let Ok(mut cache) = SCAN_PREVIEW_STATE.write() {
236238
cache.remove(&preview_id);
237239
}
238240

239241
match result {
240-
Ok(()) => {
242+
Ok(batch) => {
241243
if state.cancelled.load(Ordering::Relaxed) {
242244
let _ = app.emit(
243245
"scan-preview-cancelled",
@@ -246,8 +248,9 @@ async fn run_volume_scan_preview(
246248
},
247249
);
248250
} else {
249-
// Cache results: volume scans don't produce per-file FileInfo,
250-
// but the cache stores aggregate stats that copy_between_volumes can reuse.
251+
// Cache results: volume scans don't produce per-file FileInfo, but
252+
// the cache stores aggregate stats AND per-path scan results so
253+
// copy_between_volumes can reuse both without re-statting.
251254
if let Ok(mut cache) = SCAN_PREVIEW_RESULTS.write() {
252255
cache.insert(
253256
preview_id.clone(),
@@ -256,6 +259,7 @@ async fn run_volume_scan_preview(
256259
dirs: Vec::new(),
257260
file_count: total_files,
258261
total_bytes,
262+
per_path: batch.per_path,
259263
},
260264
);
261265
}

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ use super::types::{
1313
ConflictResolution, OperationEventSink, OperationStatus, OperationSummary, WriteOperationPhase, WriteOperationType,
1414
WriteProgressEvent,
1515
};
16+
use crate::file_system::volume::CopyScanResult;
1617

1718
// ============================================================================
1819
// Operation intent (state machine for cancellation)
@@ -376,6 +377,12 @@ pub(super) struct CachedScanResult {
376377
pub dirs: Vec<PathBuf>,
377378
pub file_count: usize,
378379
pub total_bytes: u64,
380+
/// Per-source-path scan results from volume scans. Empty for local-FS
381+
/// previews (the `files` Vec already carries everything the local copy
382+
/// engine needs). Populated by `run_volume_scan_preview` so the copy
383+
/// pipeline's cached branch can rebuild `source_hints` without per-path
384+
/// `is_directory` probes (which on MTP each list the parent dir).
385+
pub per_path: Vec<(PathBuf, CopyScanResult)>,
379386
}
380387

381388
/// Global cache for scan preview states.
@@ -461,6 +468,10 @@ pub(super) struct ScanResult {
461468
/// Not including directories.
462469
pub file_count: usize,
463470
pub total_bytes: u64,
471+
/// Per-source-path scan results, populated by volume scan previews so the
472+
/// copy pipeline can seed `source_hints` without re-statting. Empty for
473+
/// local-FS scans.
474+
pub per_path: Vec<(PathBuf, CopyScanResult)>,
464475
}
465476

466477
// ============================================================================

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

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -319,22 +319,30 @@ pub(crate) async fn copy_volumes_with_progress(
319319
total_files = cached.file_count;
320320
total_bytes = cached.total_bytes;
321321
log::debug!(
322-
"copy_volumes_with_progress: reused cached scan for operation_id={}, files={}, bytes={}",
322+
"copy_volumes_with_progress: reused cached scan for operation_id={}, files={}, bytes={}, per_path={}",
323323
operation_id,
324324
total_files,
325-
total_bytes
325+
total_bytes,
326+
cached.per_path.len()
326327
);
327-
// TODO: extend the preview cache to carry per-source type + size so this
328-
// branch doesn't need to re-stat. For now, the preview path already saved
329-
// one full scan per source, and this extra stat is bounded by source count
330-
// and the compound fast-path falls back cleanly when size is unknown.
331-
for source_path in source_paths {
332-
let is_dir = source_volume.is_directory(source_path).await.unwrap_or(false);
328+
// Volume scan previews carry per-path results so we can seed source_hints
329+
// directly. Without this, we'd `is_directory` each path here, and on MTP
330+
// every `is_directory` lists the parent dir (15k photos in /DCIM/Camera =
331+
// 15k sequential parent listings, ~2 min stall before the copy starts).
332+
// Local-FS scans don't populate per_path; the unwrap_or default leaves
333+
// source_hints empty (the conflict-resolution and SMB compound fast-paths
334+
// both fall back cleanly when a hint is missing).
335+
for (source_path, scan) in cached.per_path {
336+
let size = if scan.top_level_is_directory {
337+
0
338+
} else {
339+
scan.total_bytes
340+
};
333341
source_hints.insert(
334-
source_path.clone(),
342+
source_path,
335343
SourceHint {
336-
is_directory: is_dir,
337-
size: 0,
344+
is_directory: scan.top_level_is_directory,
345+
size,
338346
},
339347
);
340348
}

0 commit comments

Comments
 (0)