Skip to content

Commit 5cdf989

Browse files
committed
Transfer complete toast: honest wording when files were skipped
- "Copy complete: 0 files" was misleading whenever the user picked "Skip" upfront and the system honored it. The toast now reports copied vs skipped separately and, for the mixed case, summarizes outcome at the target. - `WriteCompleteEvent` gains `files_skipped: usize`. Driver-based paths (copy local, volume copy, volume move) read it straight from `TransferLoopOutcome`. Local move (same-FS + cross-FS staging) gets a manual counter threaded through `merge_move_directory` and the conflict-resolution sites. Delete/trash emit 0 (no skip concept there). - New pure helper `composeTransferCompleteToast` in `transfer/transfer-complete-toast.ts` picks the right wording by op type and copied/skipped split. Move uses "already at target" phrasing instead of copy's "now at target" because the source file stays put when a move is skipped. Single-file cases use singular wording. - 15 unit tests pin the matrix (copy/move/trash/delete × all-copied / all-skipped / mixed / single). - Toast `timeoutMs` bumped from 4s to 7s so the longer mixed/all-skipped sentences are readable. - Reported by a user test.
1 parent 1f4219a commit 5cdf989

16 files changed

Lines changed: 212 additions & 17 deletions

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,7 @@ pub(super) fn copy_files_with_progress_inner(
395395
operation_id: operation_id.to_string(),
396396
operation_type: WriteOperationType::Copy,
397397
files_processed: files_done,
398+
files_skipped: outcome.files_skipped,
398399
bytes_processed: bytes_done,
399400
});
400401
Ok(())

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@ pub(super) fn delete_files_with_progress_inner(
157157
operation_id: operation_id.to_string(),
158158
operation_type: WriteOperationType::Delete,
159159
files_processed: files_done,
160+
files_skipped: 0,
160161
bytes_processed: bytes_done,
161162
});
162163

@@ -774,6 +775,7 @@ pub(super) async fn delete_volume_files_with_progress_inner(
774775
operation_id: operation_id.to_string(),
775776
operation_type: WriteOperationType::Delete,
776777
files_processed: files_done,
778+
files_skipped: 0,
777779
bytes_processed: bytes_done,
778780
});
779781

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ fn move_with_rename(
113113
config: &WriteOperationConfig,
114114
) -> Result<(), WriteOperationError> {
115115
let mut files_done = 0;
116+
let mut files_skipped = 0usize;
116117
let mut apply_to_all_resolution: Option<ConflictResolution> = None;
117118
let mut move_tx = MoveTransaction::new();
118119

@@ -143,6 +144,7 @@ fn move_with_rename(
143144
state,
144145
&mut apply_to_all_resolution,
145146
&mut move_tx,
147+
&mut files_skipped,
146148
)?;
147149
} else if dest_path.exists() {
148150
// File-to-file (or type mismatch) conflict
@@ -161,6 +163,7 @@ fn move_with_rename(
161163
}
162164
None => {
163165
// Skip this file
166+
files_skipped += 1;
164167
continue;
165168
}
166169
}
@@ -211,6 +214,7 @@ fn move_with_rename(
211214
operation_id: operation_id.to_string(),
212215
operation_type: WriteOperationType::Move,
213216
files_processed: files_done,
217+
files_skipped,
214218
bytes_processed: 0, // Rename doesn't track bytes
215219
});
216220

@@ -235,6 +239,7 @@ fn merge_move_directory(
235239
state: &Arc<WriteOperationState>,
236240
apply_to_all_resolution: &mut Option<ConflictResolution>,
237241
move_tx: &mut MoveTransaction,
242+
files_skipped: &mut usize,
238243
) -> Result<(), WriteOperationError> {
239244
let entries = fs::read_dir(source_dir).with_path(source_dir)?;
240245

@@ -265,6 +270,7 @@ fn merge_move_directory(
265270
state,
266271
apply_to_all_resolution,
267272
move_tx,
273+
files_skipped,
268274
)?;
269275
} else if dest_child.exists() {
270276
// File conflict (or type mismatch)
@@ -283,6 +289,7 @@ fn merge_move_directory(
283289
}
284290
None => {
285291
// Skip: source file stays in place
292+
*files_skipped += 1;
286293
continue;
287294
}
288295
}
@@ -336,6 +343,7 @@ fn move_with_staging(
336343
let mut transaction = CopyTransaction::new();
337344
let mut files_done = 0;
338345
let mut bytes_done = 0u64;
346+
let mut files_skipped = 0usize;
339347
let mut last_progress_time = Instant::now();
340348
let mut apply_to_all_resolution: Option<ConflictResolution> = None;
341349
let mut created_dirs: HashSet<PathBuf> = HashSet::new();
@@ -445,6 +453,7 @@ fn move_with_staging(
445453
state,
446454
&mut apply_to_all_resolution,
447455
&mut staging_move_tx,
456+
&mut files_skipped,
448457
)?;
449458
} else if final_path.exists() {
450459
// File conflict (or type mismatch)
@@ -470,6 +479,7 @@ fn move_with_staging(
470479
} else {
471480
let _ = fs::remove_file(&staged_path);
472481
}
482+
files_skipped += 1;
473483
continue;
474484
}
475485
}
@@ -509,6 +519,7 @@ fn move_with_staging(
509519
operation_id: operation_id.to_string(),
510520
operation_type: WriteOperationType::Move,
511521
files_processed: files_done,
522+
files_skipped,
512523
bytes_processed: bytes_done,
513524
});
514525

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,7 @@ pub(super) fn trash_files_with_progress(
230230
operation_id: operation_id.to_string(),
231231
operation_type: WriteOperationType::Trash,
232232
files_processed: items_done,
233+
files_skipped: 0,
233234
bytes_processed: bytes_done,
234235
},
235236
);

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,12 +181,20 @@ impl WriteProgressEvent {
181181
}
182182

183183
/// Completion event payload.
184+
///
185+
/// `files_processed` counts every source the operation considered (transferred + skipped),
186+
/// matching the driver's `files_done`. `files_skipped` is the subset that was skipped via
187+
/// conflict resolution (bulk pre-known-conflict skip, per-iter Skip from the resolver, or
188+
/// closure-side Skip such as same-inode self-copy). For delete/trash, skipping isn't a
189+
/// concept and the field is always 0. The FE uses both to compose user-facing summaries
190+
/// like "Copy complete: 3 copied, 2 skipped" instead of the misleading "0 files".
184191
#[derive(Debug, Clone, Serialize, Deserialize)]
185192
#[serde(rename_all = "camelCase")]
186193
pub struct WriteCompleteEvent {
187194
pub operation_id: String,
188195
pub operation_type: WriteOperationType,
189196
pub files_processed: usize,
197+
pub files_skipped: usize,
190198
pub bytes_processed: u64,
191199
}
192200

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1157,6 +1157,7 @@ pub(crate) async fn copy_volumes_with_progress(
11571157
operation_id: operation_id.to_string(),
11581158
operation_type: WriteOperationType::Copy,
11591159
files_processed: files_done,
1160+
files_skipped,
11601161
bytes_processed: bytes_done,
11611162
});
11621163

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -483,6 +483,7 @@ pub(super) async fn move_volumes_with_progress(
483483
let copy_failure_ctx: Option<(VolumeError, PathBuf)> = failure_ctx_cell.lock().unwrap().take();
484484
let files_done = outcome.files_done;
485485
let bytes_done = outcome.bytes_done;
486+
let files_skipped = outcome.files_skipped;
486487

487488
match outcome.intent {
488489
PostLoopIntent::Completed => {
@@ -496,6 +497,7 @@ pub(super) async fn move_volumes_with_progress(
496497
operation_id: operation_id.to_string(),
497498
operation_type: WriteOperationType::Move,
498499
files_processed: files_done,
500+
files_skipped,
499501
bytes_processed: bytes_done,
500502
});
501503
Ok(())
@@ -866,6 +868,7 @@ pub(super) async fn move_within_same_volume_with_progress(
866868

867869
let files_moved = outcome.files_done;
868870
let bytes_moved = outcome.bytes_done;
871+
let files_skipped = outcome.files_skipped;
869872

870873
match outcome.intent {
871874
PostLoopIntent::Completed => {
@@ -879,6 +882,7 @@ pub(super) async fn move_within_same_volume_with_progress(
879882
operation_id: operation_id.to_string(),
880883
operation_type: WriteOperationType::Move,
881884
files_processed: files_moved,
885+
files_skipped,
882886
bytes_processed: bytes_moved,
883887
});
884888
Ok(())

apps/desktop/src/lib/file-explorer/pane/DialogManager.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@
7070
preKnownConflicts: string[],
7171
) => void
7272
onTransferCancel: () => void
73-
onTransferComplete: (filesProcessed: number, bytesProcessed: number) => void
73+
onTransferComplete: (filesProcessed: number, filesSkipped: number, bytesProcessed: number) => void
7474
onTransferCancelled: (filesProcessed: number) => void
7575
onTransferError: (error: WriteOperationError, friendly?: FriendlyError) => void
7676
onTransferErrorClose: () => void

apps/desktop/src/lib/file-explorer/pane/DualPaneExplorer.svelte

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2572,8 +2572,8 @@
25722572
onTransferCancel={() => {
25732573
dialogs.handleTransferCancel()
25742574
}}
2575-
onTransferComplete={(files: number, bytes: number) => {
2576-
dialogs.handleTransferComplete(files, bytes)
2575+
onTransferComplete={(files: number, skipped: number, bytes: number) => {
2576+
dialogs.handleTransferComplete(files, skipped, bytes)
25772577
}}
25782578
onTransferCancelled={(files: number) => {
25792579
dialogs.handleTransferCancelled(files)

apps/desktop/src/lib/file-explorer/pane/dialog-state.svelte.ts

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { formatBytes, refreshListing } from '$lib/tauri-commands'
22
import { listen, findFileIndex } from '$lib/tauri-commands'
3-
import { formatNumber } from '$lib/file-explorer/selection/selection-info-utils'
43
import { addToast } from '$lib/ui/toast'
4+
import { composeTransferCompleteToast } from '$lib/file-operations/transfer/transfer-complete-toast'
55
import { getAppLogger } from '$lib/logging/logger'
66
import { moveCursorToNewFolder } from '$lib/file-operations/mkdir/new-folder-operations'
77
import type { TransferDialogPropsData } from './transfer-operations'
@@ -344,16 +344,17 @@ export function createDialogState(deps: DialogStateDeps) {
344344
deps.onRefocus()
345345
},
346346

347-
handleTransferComplete(filesProcessed: number, bytesProcessed: number) {
347+
handleTransferComplete(filesProcessed: number, filesSkipped: number, bytesProcessed: number) {
348348
const op = transferProgressProps?.operationType ?? 'copy'
349349
const opLabel = op === 'copy' ? 'Copy' : op === 'move' ? 'Move' : op === 'trash' ? 'Trash' : 'Delete'
350-
log.info(`${opLabel} complete: ${String(filesProcessed)} files (${formatBytes(bytesProcessed)})`)
351-
const itemWord = filesProcessed === 1 ? 'file' : 'files'
352-
const toastMessage =
353-
op === 'trash'
354-
? `Moved ${formatNumber(filesProcessed)} ${itemWord} to trash`
355-
: `${opLabel} complete: ${formatNumber(filesProcessed)} ${itemWord}`
356-
addToast(toastMessage)
350+
log.info(
351+
`${opLabel} complete: ${String(filesProcessed)} files (${String(filesSkipped)} skipped, ${formatBytes(bytesProcessed)})`,
352+
)
353+
const toastMessage = composeTransferCompleteToast({ operationType: op, filesProcessed, filesSkipped })
354+
// Bump the timeout for the long mixed/all-skipped sentences (default 4s reads as
355+
// a flicker for users still parsing the second clause). 7s comfortably covers the
356+
// longest variant without staying around long enough to nag.
357+
addToast(toastMessage, { timeoutMs: 7000 })
357358

358359
refreshPanesAfterTransfer()
359360
getSourcePaneRef()?.clearOperationSnapshot()

0 commit comments

Comments
 (0)