Skip to content

Commit e3560a3

Browse files
committed
Add delete/trash feature (F8)
- Trash by default (F8), permanent delete via ⇧F8 - Confirmation dialog with file list, scan preview, deep stats - Volume fsType + supportsTrash detection via statfs (proactive no-trash warnings for FAT32, exFAT, network mounts) - Batch trash backend with progress, cancellation, partial failure - Extracted move_to_trash_sync to write_operations/trash.rs, fixed dangling symlink bug (symlink_metadata instead of exists) - Extended TransferProgressDialog for delete/trash with 400ms minimum display, phase labels, optional transfer-specific props - Post-delete cursor stays at same position index (apply-diff fix) - MCP delete tool, error messages for locked files - Removed unused confirmBeforeDelete/deletePermanently setting
1 parent 3b4b176 commit e3560a3

43 files changed

Lines changed: 2576 additions & 250 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/desktop/coverage-allowlist.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,9 @@
183183
},
184184
"tooltip/tooltip.ts": {
185185
"reason": "DOM-dependent singleton tooltip, requires document.body and getBoundingClientRect"
186+
},
187+
"file-operations/delete/DeleteDialog.svelte": {
188+
"reason": "UI modal, logic tested in delete-dialog-utils.test.ts"
186189
}
187190
}
188191
}

apps/desktop/src-tauri/src/commands/CLAUDE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,13 @@ immediately to business-logic modules. No significant logic lives here.
88
| File | Domain | Notes |
99
|------|--------|-------|
1010
| `mod.rs` | Re-exports | `mtp`, `network`, `volumes` gated behind `#[cfg(target_os = "macos")]` |
11-
| `file_system.rs` | File listing & writes | Largest file. Streaming + virtual-scroll listing API, write ops, scan preview, conflict resolution, volume copy, native drag, self-drag overlay. Contains `expand_tilde()`. |
11+
| `file_system.rs` | File listing & writes | Largest file. Streaming + virtual-scroll listing API, write ops (copy, move, delete, trash), scan preview, conflict resolution, volume copy, native drag, self-drag overlay. Contains `expand_tilde()`. |
1212
| `volumes.rs` | Volume management | `list_volumes`, `get_default_volume_id`, `find_containing_volume`, `get_volume_space` |
1313
| `mtp.rs` | MTP devices | Full MTP command surface (connect, disconnect, list, download, upload, delete, rename, move, scan) |
1414
| `network.rs` | SMB/network shares | Discovery, share listing, keychain, mounting. |
1515
| `font_metrics.rs` | Font metrics cache | `store_font_metrics`, `has_font_metrics` |
1616
| `icons.rs` | File icons | `get_icons`, `refresh_directory_icons`, cache clear |
17-
| `rename.rs` | Rename / trash | `move_to_trash` (NSFileManager), `check_rename_permission`, `check_rename_validity`, `rename_file` |
17+
| `rename.rs` | Rename / trash | `move_to_trash` (delegates to `write_operations::trash::move_to_trash_sync`), `check_rename_permission`, `check_rename_validity`, `rename_file` |
1818
| `file_viewer.rs` | File viewer | Session lifecycle, line search, word wrap, menu state |
1919
| `ui.rs` | UI / menu | Context menu, Finder reveal, clipboard, Quick Look, Get Info, view mode |
2020
| `settings.rs` | Settings | Port availability check, watcher debounce setting, menu accelerator updates |

apps/desktop/src-tauri/src/commands/file_system.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ use crate::file_system::{
2020
list_directory_start_streaming as ops_list_directory_start_streaming,
2121
list_directory_start_with_volume as ops_list_directory_start_with_volume, move_files_start as ops_move_files_start,
2222
resort_listing as ops_resort_listing, scan_for_volume_copy as ops_scan_for_volume_copy,
23+
trash_files_start as ops_trash_files_start,
2324
};
2425
use std::path::{Path, PathBuf};
2526
#[cfg(target_os = "macos")]
@@ -325,6 +326,20 @@ pub async fn delete_files(
325326
ops_delete_files_start(app, sources, config).await
326327
}
327328

329+
/// Moves files to macOS Trash. Same events as `copy_files` but with `operationType: trash`.
330+
#[tauri::command]
331+
pub async fn trash_files(
332+
app: tauri::AppHandle,
333+
sources: Vec<String>,
334+
item_sizes: Option<Vec<u64>>,
335+
config: Option<WriteOperationConfig>,
336+
) -> Result<WriteOperationStartResult, WriteOperationError> {
337+
let sources: Vec<PathBuf> = sources.iter().map(|s| PathBuf::from(expand_tilde(s))).collect();
338+
let config = config.unwrap_or_default();
339+
340+
ops_trash_files_start(app, sources, item_sizes, config).await
341+
}
342+
328343
#[tauri::command]
329344
pub fn cancel_write_operation(operation_id: String, rollback: bool) {
330345
ops_cancel_write_operation(&operation_id, rollback);

apps/desktop/src-tauri/src/commands/rename.rs

Lines changed: 1 addition & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
use std::path::{Path, PathBuf};
44

55
use super::file_system::expand_tilde;
6+
use crate::file_system::write_operations::trash::move_to_trash_sync;
67

78
// ============================================================================
89
// Rename operations
@@ -19,36 +20,6 @@ pub async fn move_to_trash(path: String) -> Result<(), String> {
1920
.map_err(|e| format!("Task failed: {}", e))?
2021
}
2122

22-
/// Synchronous trash implementation using macOS NSFileManager.trashItem.
23-
#[cfg(target_os = "macos")]
24-
fn move_to_trash_sync(path: &Path) -> Result<(), String> {
25-
use objc2_foundation::{NSFileManager, NSString, NSURL};
26-
27-
if !path.exists() {
28-
return Err(format!("'{}' doesn't exist", path.display()));
29-
}
30-
31-
let path_str = path.to_string_lossy();
32-
let ns_path = NSString::from_str(&path_str);
33-
let url = NSURL::fileURLWithPath(&ns_path);
34-
let file_manager = NSFileManager::defaultManager();
35-
36-
// trashItemAtURL:resultingItemURL:error: moves the item to Trash.
37-
// We pass None for resultingItemURL since we don't need the trash location.
38-
file_manager
39-
.trashItemAtURL_resultingItemURL_error(&url, None)
40-
.map_err(|e| format!("Failed to move to trash: {}", e))?;
41-
Ok(())
42-
}
43-
44-
#[cfg(not(target_os = "macos"))]
45-
fn move_to_trash_sync(path: &Path) -> Result<(), String> {
46-
Err(format!(
47-
"Moving to trash is not supported on this platform for '{}'",
48-
path.display()
49-
))
50-
}
51-
5223
/// Checks if a file/folder can be renamed (parent writable, not immutable, not SIP-protected, not locked).
5324
#[tauri::command]
5425
pub async fn check_rename_permission(path: String) -> Result<(), String> {

apps/desktop/src-tauri/src/file_system/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ pub(crate) use watcher::{DirectoryDiff, compute_diff};
4949
pub use write_operations::{
5050
OperationStatus, OperationSummary, WriteOperationConfig, WriteOperationError, WriteOperationStartResult,
5151
cancel_all_write_operations, cancel_write_operation, copy_files_start, delete_files_start, get_operation_status,
52-
list_active_operations, move_files_start,
52+
list_active_operations, move_files_start, trash_files_start,
5353
};
5454
// Re-export volume copy types and functions
5555
// TODO: Remove this allow once volume_copy is integrated into Tauri commands (Phase 5)

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

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,26 @@
11
# Write operations
22

3-
Copy, move, and delete with streaming progress, cancellation, conflict resolution, and rollback. macOS only.
3+
Copy, move, delete, and trash with streaming progress, cancellation, conflict resolution, and rollback. macOS only.
44

55
## Purpose
66

7-
Implements the three destructive file operations as background tasks that stream Tauri events to the frontend. Every
7+
Implements the four destructive file operations as background tasks that stream Tauri events to the frontend. Every
88
operation is cancellable, reports byte-level progress, and handles edge cases: symlink loops, same-inode overwrites,
99
network mounts, cross-filesystem moves, and name/path length limits.
1010

1111
## Files
1212

1313
| File | Responsibility |
1414
|------|----------------|
15-
| `mod.rs` | Public API: `copy_files_start`, `move_files_start`, `delete_files_start`. Validates inputs, creates `WriteOperationState`, spawns `tokio::spawn` + `spawn_blocking`. |
15+
| `mod.rs` | Public API: `copy_files_start`, `move_files_start`, `delete_files_start`, `trash_files_start`. Validates inputs, creates `WriteOperationState`, spawns `tokio::spawn` + `spawn_blocking`. |
1616
| `types.rs` | All serializable types: events, config, errors, results. `WriteOperationConfig`, `ConflictResolution`, `WriteOperationError`, `DryRunResult`, scan preview events. |
1717
| `state.rs` | Two `LazyLock<RwLock<HashMap>>` caches (`WRITE_OPERATION_STATE`, `OPERATION_STATUS_CACHE`). `WriteOperationState`, `CopyTransaction`, `ScanResult`, `FileInfo`. |
1818
| `helpers.rs` | Validation (`validate_sources`, `validate_destination_writable` via `libc::access`, `validate_disk_space` via `statvfs`). Conflict resolution (condvar wait for Stop mode). `safe_overwrite_file`/`safe_overwrite_dir` (temp+rename). `find_unique_name`. `run_cancellable`. `is_same_filesystem` (device IDs). |
1919
| `scan.rs` | `scan_sources` (recursive walk, emits progress), `dry_run_scan`, scan preview subsystem (`start_scan_preview`, `cancel_scan_preview`). |
2020
| `copy.rs` | `copy_files_with_progress`: scan → disk space check → per-file copy via `copy_single_item`. `CopyTransaction` for rollback. |
2121
| `move_op.rs` | Same-fs: `fs::rename`. Cross-fs: copy to `.cmdr-staging-<uuid>`, atomic rename, delete sources. |
2222
| `delete.rs` | Scan, delete files first, then directories in reverse/deepest-first order. Not rollbackable. |
23+
| `trash.rs` | `move_to_trash_sync()` (ObjC `trashItemAtURL` wrapper, reused by `commands/rename.rs`) and `trash_files_with_progress()` (batch trash with per-item progress, cancellation, partial failure). Uses `symlink_metadata()` for existence checks (handles dangling symlinks). |
2324
| `copy_strategy.rs` | Strategy selection per file: network FS → chunked copy; overwrite → temp+rename; otherwise → macOS `copyfile(3)`. |
2425
| `macos_copy.rs` | FFI to macOS `copyfile(3)`. Preserves xattrs, ACLs, resource forks, Finder metadata. Supports APFS `clonefile`. |
2526
| `chunked_copy.rs` | 1 MB chunked read/write for network mounts. Checks cancellation between chunks. Copies xattrs, ACLs, timestamps. |
@@ -82,6 +83,11 @@ actual `copy_files_start` can consume the cache via `preview_id` in `WriteOperat
8283
- Needs safe overwrite → `safe_overwrite_file`
8384
- Otherwise → `copy_single_file_native` (macOS `copyfile(3)`, supports `COPYFILE_CLONE` for APFS instant copies)
8485

86+
**Trash has no scan phase.** `trashItemAtURL` is atomic per top-level item (the OS moves the entire tree), so trash
87+
doesn't need the recursive scan that delete/copy use. Progress tracks top-level items, with optional byte-level progress
88+
from pre-computed item sizes. Partial failure is supported: if some items fail, others still succeed. The core
89+
`move_to_trash_sync()` is extracted to `trash.rs` and reused by `commands/rename.rs`.
90+
8591
**Special files skipped.** Sockets, FIFOs, and device files are filtered out during scan.
8692

8793
**`volume_copy` path is incomplete.** The three `volume_*` files are Phase 5 work, but are publicly re-exported from `mod.rs` and at least partially wired up.
@@ -90,7 +96,7 @@ actual `copy_files_start` can consume the cache via `preview_id` in `WriteOperat
9096

9197
| Event | Trigger |
9298
|-------|---------|
93-
| `write-progress` | Every ~200ms during copy/move/delete |
99+
| `write-progress` | Every ~200ms during copy/move/delete/trash |
94100
| `write-conflict` | Stop mode hit a conflicting destination file |
95101
| `write-complete` | Operation finished successfully |
96102
| `write-cancelled` | Operation cancelled (includes `rolled_back` flag) |

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

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ pub(crate) mod macos_copy;
2626
mod move_op;
2727
mod scan;
2828
mod state;
29+
pub(crate) mod trash;
2930
mod types;
3031
mod volume_conflict;
3132
mod volume_copy;
@@ -48,6 +49,7 @@ use move_op::move_files_with_progress;
4849
#[cfg(not(test))]
4950
use state::WriteOperationState;
5051
use state::{WRITE_OPERATION_STATE, register_operation_status, unregister_operation_status};
52+
use trash::trash_files_with_progress;
5153

5254
// Re-export public types
5355
pub use scan::{cancel_scan_preview, start_scan_preview};
@@ -322,6 +324,85 @@ pub async fn delete_files_start(
322324
})
323325
}
324326

327+
/// Starts a trash operation in the background.
328+
///
329+
/// Moves top-level items to the macOS Trash via `NSFileManager.trashItemAtURL`.
330+
/// Supports cancellation between items and partial failure (some items may fail
331+
/// while others succeed).
332+
///
333+
/// # Arguments
334+
/// * `app` - Tauri app handle for event emission
335+
/// * `sources` - Top-level items to trash
336+
/// * `item_sizes` - Optional per-item sizes for byte-level progress
337+
/// * `config` - Operation configuration (only `progress_interval_ms` is used)
338+
pub async fn trash_files_start(
339+
app: tauri::AppHandle,
340+
sources: Vec<PathBuf>,
341+
item_sizes: Option<Vec<u64>>,
342+
config: WriteOperationConfig,
343+
) -> Result<WriteOperationStartResult, WriteOperationError> {
344+
// Validate inputs
345+
validate_sources(&sources)?;
346+
347+
let operation_id = Uuid::new_v4().to_string();
348+
let state = Arc::new(WriteOperationState {
349+
cancelled: Arc::new(AtomicBool::new(false)),
350+
skip_rollback: AtomicBool::new(false),
351+
progress_interval: Duration::from_millis(config.progress_interval_ms),
352+
pending_resolution: std::sync::RwLock::new(None),
353+
conflict_condvar: std::sync::Condvar::new(),
354+
conflict_mutex: std::sync::Mutex::new(false),
355+
});
356+
357+
// Store state for cancellation
358+
if let Ok(mut cache) = WRITE_OPERATION_STATE.write() {
359+
cache.insert(operation_id.clone(), Arc::clone(&state));
360+
}
361+
362+
// Register operation status for query APIs
363+
register_operation_status(&operation_id, WriteOperationType::Trash);
364+
365+
let operation_id_for_spawn = operation_id.clone();
366+
367+
// Spawn background task
368+
tokio::spawn(async move {
369+
let operation_id_for_cleanup = operation_id_for_spawn.clone();
370+
let app_for_error = app.clone();
371+
372+
let result = tokio::task::spawn_blocking(move || {
373+
trash_files_with_progress(&app, &operation_id_for_spawn, &state, &sources, item_sizes.as_deref())
374+
})
375+
.await;
376+
377+
// Clean up state
378+
if let Ok(mut cache) = WRITE_OPERATION_STATE.write() {
379+
cache.remove(&operation_id_for_cleanup);
380+
}
381+
unregister_operation_status(&operation_id_for_cleanup);
382+
383+
// Handle task panic
384+
if let Err(e) = result {
385+
use tauri::Emitter;
386+
let _ = app_for_error.emit(
387+
"write-error",
388+
WriteErrorEvent {
389+
operation_id: operation_id_for_cleanup,
390+
operation_type: WriteOperationType::Trash,
391+
error: WriteOperationError::IoError {
392+
path: String::new(),
393+
message: format!("Task failed: {}", e),
394+
},
395+
},
396+
);
397+
}
398+
});
399+
400+
Ok(WriteOperationStartResult {
401+
operation_id,
402+
operation_type: WriteOperationType::Trash,
403+
})
404+
}
405+
325406
#[cfg(test)]
326407
mod integration_test;
327408
#[cfg(test)]

0 commit comments

Comments
 (0)