Skip to content

Commit 2dfd17b

Browse files
committed
File ops: Overwrite-all-smaller / older conflict actions
- New `ConflictResolution::OverwriteSmaller` / `OverwriteOlder` variants. Strict comparison: equal sizes / equal mtimes / unknown metadata always reduce to `Skip` so a borderline file is never silently overwritten. Implemented in `helpers::reduce_conditional_resolution` (local FS) and `volume_conflict::reduce_volume_conditional_resolution` (volume, async, with size hints + `get_metadata` for mtime). - Per-file conflict dialog reorganized into a 2-col × 4-row grid: single-file action left, apply-to-all right; the two new conditional bulk buttons live in the 4th row (always apply-to-all by design). - `TransferDialog` upfront radios extended with the two new policies, wrap naturally via flex-wrap. - Volume `WriteConflictEvent` now populates `source_modified` / `destination_modified` / `destination_is_newer` via `get_metadata`, so MTP/SMB copies render `(newer)` annotations like local-FS already did. Stale "we can't easily get modification times from Volume trait" comment removed. - Each Skip under a conditional policy logs the reason (`target: "conflict_resolution"`), so an SMB copy with `OverwriteOlder` against a backend that doesn't surface `modified_at` is debuggable instead of silent. - The "apply-to-all" persistence stores the *original* conditional variant, so each subsequent conflict re-evaluates per-file against its own source/dest. - 29 new Rust unit tests pin the strict-comparison contract across all axes (local + volume, with and without size hints, missing metadata, equal/larger/older/newer, axis independence). - 3 new Playwright specs (`conflict-overwrite-conditional.spec.ts`) cover both the upfront radios and the per-file button. - `write_operations/CLAUDE.md` and `file-operations/CLAUDE.md` updated.
1 parent be1350d commit 2dfd17b

12 files changed

Lines changed: 1140 additions & 33 deletions

File tree

apps/desktop/src-tauri/src/file_system/write_operations/CLAUDE.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ network mounts, cross-filesystem moves, and name/path length limits.
2929
| `chunked_copy.rs` | 1 MB chunked read/write, the default copy method for all non-APFS-clonefile copies on macOS and network copies on Linux. Checks cancellation between chunks. Copies xattrs, ACLs, timestamps. |
3030
| `volume_copy.rs` | Volume-to-volume copy (Local↔MTP↔SMB): `copy_between_volumes`, `scan_for_volume_copy`. Uses `OperationEventSink` (not `AppHandle` directly) for event emission. Handles conflict detection, resolution, progress, rollback (delete all copied files in reverse with progress), and partial-file cleanup on cancel. Shared `map_volume_error` helper. |
3131
| `volume_move.rs` | Volume-to-volume move: `move_between_volumes`, `move_within_same_volume`. Same-volume uses `Volume::rename`; cross-volume does copy+delete. |
32-
| `volume_conflict.rs`, `volume_strategy.rs` | Conflict resolution (Stop/Skip/Overwrite/Rename) and copy strategy selection for volume operations. |
32+
| `volume_conflict.rs`, `volume_strategy.rs` | Conflict resolution (Stop/Skip/Overwrite/Rename/OverwriteSmaller/OverwriteOlder) and copy strategy selection for volume operations. `volume_conflict.rs` mirrors the local-FS `reduce_conditional_resolution` from `helpers.rs` with its own `reduce_volume_conditional_resolution` (async, uses size hints + `get_metadata` for mtime). |
3333
| `transfer_driver.rs` | Shared per-source transfer driver for copy/move ops. Owns the bulk-skip prelude, per-iter cancellation check, conflict-resolve dispatch (async path), per-iter skip accounting, and paired progress/status emit. Two sibling entry points: `drive_transfer_serial_sync` (used by `copy.rs::copy_files_with_progress_inner`; closure captures `&mut CopyTransaction` / `&mut created_dirs` / `&mut SourceItemTracker`) and `drive_transfer_serial_async` (used by `volume_copy::copy_volumes_with_progress`'s serial path and both `volume_move` paths; driver owns top-level conflict detection + dispatch). The `FuturesUnordered` concurrent path in `volume_copy.rs` stays inline (1-of-4 abstraction; see plan § "Concurrent driver scope"). |
3434
| `tests.rs` | Unit tests. |
3535
| `copy_integration_test.rs` | Copy operation integration tests (permissions, symlinks, xattrs, edge cases). |
@@ -108,6 +108,8 @@ of canonicalized paths.
108108
**Safe overwrite: temp + backup + rename.** Steps: copy source → `dest.cmdr-tmp-<uuid>`, rename dest → `dest.cmdr-backup-<uuid>`,
109109
rename temp → dest, delete backup. The original is intact until step 3 completes.
110110

111+
**Conditional conflict policies (`OverwriteSmaller` / `OverwriteOlder`)** reduce per-file. The user picks "Overwrite all smaller" / "Overwrite all older" either upfront (TransferDialog radios) or via the per-file conflict dialog's apply-to-all buttons. Each conflict re-evaluates against its own source/dest metadata: `OverwriteSmaller` overwrites only when `dst.len() < src.len()`, `OverwriteOlder` overwrites only when `dst.modified() < src.modified()`. Equal sizes / equal mtimes / unknown metadata all reduce to `Skip` — strict comparison so a borderline file is never silently overwritten. Implemented by `helpers::reduce_conditional_resolution` (sync, local FS) and `volume_conflict::reduce_volume_conditional_resolution` (async, volume backends). Both log a `target: "conflict_resolution"` info line on every Skip with the reason (not-strictly-smaller, not-strictly-older, missing metadata), so users running an MTP/SMB copy who picked one of these can see in the operation log why their conflicts got skipped instead of being puzzled by silence. **The apply-to-all storage saves the *original* conditional variant**, not the reduced one — subsequent conflicts re-run the comparison against their own files. Tested exhaustively across the comparison axes in `helpers::conditional_resolution_tests` and `volume_conflict::tests::volume_*`.
112+
111113
**Stop-mode conflict resolution.** Emits `write-conflict` event, then blocks on a `tokio::sync::oneshot` channel
112114
(`blocking_recv()` inside `spawn_blocking`). A new oneshot channel is created per conflict. Frontend calls
113115
`resolve_write_conflict(operation_id, resolution, apply_to_all)` which takes the stored `Sender` and sends the
@@ -183,7 +185,7 @@ frontend's `handleError` removes all listeners on first receipt.
183185
threads (used for temp/backup file cleanup, not for user-visible rollback). If the network mount disconnects or the app
184186
exits, partial files or staging directories may remain on disk. These use the `.cmdr-` prefix, so they're recognizable.
185187

186-
**`volume_copy` path is fully wired up.** The three `volume_*` files are re-exported from `mod.rs` and called by the `copy_between_volumes` and `move_between_volumes` Tauri commands. Both copy and move operations support conflict detection and resolution (Stop/Skip/Overwrite/Rename) for all volume combinations (Local↔MTP, MTP↔MTP). Volume copy supports rollback (delete all copied files in reverse order with progress events, matching the local copy's `rollback_with_progress` pattern) and cancel cleanup (delete only the last partial file). Rollback uses `delete_volume_path_recursive` which lists directory contents via `Volume::list_directory` and deletes children before parents.
188+
**`volume_copy` path is fully wired up.** The three `volume_*` files are re-exported from `mod.rs` and called by the `copy_between_volumes` and `move_between_volumes` Tauri commands. Both copy and move operations support conflict detection and resolution (Stop/Skip/Overwrite/Rename/OverwriteSmaller/OverwriteOlder) for all volume combinations (Local↔MTP, MTP↔MTP). Volume copy supports rollback (delete all copied files in reverse order with progress events, matching the local copy's `rollback_with_progress` pattern) and cancel cleanup (delete only the last partial file). Rollback uses `delete_volume_path_recursive` which lists directory contents via `Volume::list_directory` and deletes children before parents.
187189

188190
**`write-error` carries a provider-enriched `FriendlyError` for both move and copy.** Both `move_between_volumes` and `copy_volumes_with_progress` keep the originating `VolumeError + path` alongside each `?`-propagated `WriteOperationError` via the shared `WriteFailure` struct (in `volume_copy.rs`). `WriteFailure::from_volume(path, e)` and `WriteFailure::synthetic(write_err)` are the two constructors: one captures volume context, the other doesn't (cancellation, validation, synthetic IoError). The shared `write_error_event_from(...)` helper builds a `WriteErrorEvent` from any `WriteFailure`: when the volume context is present it calls `WriteErrorEvent::with_friendly` (full `friendly_error_from_volume_error` + `enrich_with_provider` pipeline, picks up provider-specific suggestions like "This folder is managed by **MacDroid**…"); otherwise it falls back to the variant-derived `WriteErrorEvent::new` via `friendly_from_write_error`. Both move and copy paths land at the same FE quality.
189191

0 commit comments

Comments
 (0)