Skip to content

Commit 812ad07

Browse files
committed
MTP: Wire up delete, rename, and move
- New `delete_volume_files_with_progress` in `delete.rs`: scans via `volume.list_directory()`, deletes per-item with full progress events, cancellation, and dry-run support - `delete_files` and `rename_file` commands accept optional `volume_id` — routes through Volume trait for non-root volumes - MTP move implemented as two-phase copy+delete in `TransferProgressDialog`: copy via `copyBetweenVolumes`, then delete source with volume-aware delete. Three-stage progress UI (Scanning → Copying → Removing source) - MTP move guard removed from `DualPaneExplorer` - Rename flow threads `volumeId` through `FilePane` → `rename-flow` → `rename-operations` → `renameFile`, skipping `checkRenamePermission`/`checkRenameValidity` for MTP volumes - Clipboard toasts updated to suggest F5/F6 instead of generic "not supported yet" - Fixed `sourceVolumeId` bug in `pasteFromClipboard` (was using dest volume ID) - Updated CLAUDE.md files: `commands`, `write_operations`, `file-operations`, `mtp`, `rename`
1 parent 26d8eff commit 812ad07

17 files changed

Lines changed: 830 additions & 90 deletions

File tree

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ The frontend has matching TypeScript types in `$lib/tauri-commands/ipc-types.ts`
6161
- **`expand_tilde`** is applied conditionally: for `list_directory` it's gated on `volume_id == "root"`, but for write operations (copy, move, delete, scan preview) it's always applied. MTP and network volume paths must never be tilde-expanded.
6262
- **AI commands** are registered directly from `ai::manager` and `ai::suggestions` — there is no `commands/ai.rs` file.
6363
- **Platform gates.** `volumes` is macOS-only; `mtp` and `network` are macOS+Linux; `volumes_linux` is Linux-only. Individual functions also use `#[cfg]` where behaviour differs (e.g., `sync_status`).
64+
- **`delete_files` and `rename_file` accept `volume_id`.** When set to a non-root volume, `delete_files` routes to the volume-aware delete path and skips local `validate_sources` (MTP virtual paths fail `symlink_metadata`). `rename_file` passes `volume_id` through for MTP rename support; permission checks are skipped for non-root volumes.
6465
- **`start_selection_drag`** requires the main thread. It uses `app.run_on_main_thread()` plus a `std::sync::mpsc` channel to return the result synchronously.
6566
- **`list_shares_with_credentials`** has `#[allow(clippy::too_many_arguments)]` because Tauri command parameters must be top-level arguments — no struct bundling.
6667
- **`set_menu_context` and Close tab (⌘W).** When the main window loses focus, `set_menu_context("other")` disables all

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

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -340,16 +340,23 @@ pub async fn move_files(
340340
}
341341

342342
/// Recursively deletes files and directories. Same events as `copy_files`.
343+
/// When `volume_id` is provided and is not "root", routes through the Volume trait.
343344
#[tauri::command]
344345
pub async fn delete_files(
345346
app: tauri::AppHandle,
346347
sources: Vec<String>,
348+
volume_id: Option<String>,
347349
config: Option<WriteOperationConfig>,
348350
) -> Result<WriteOperationStartResult, WriteOperationError> {
349-
let sources: Vec<PathBuf> = sources.iter().map(|s| PathBuf::from(expand_tilde(s))).collect();
351+
let is_local = volume_id.as_deref().unwrap_or("root") == "root";
352+
let sources: Vec<PathBuf> = if is_local {
353+
sources.iter().map(|s| PathBuf::from(expand_tilde(s))).collect()
354+
} else {
355+
sources.iter().map(PathBuf::from).collect()
356+
};
350357
let config = config.unwrap_or_default();
351358

352-
ops_delete_files_start(app, sources, config).await
359+
ops_delete_files_start(app, sources, config, volume_id).await
353360
}
354361

355362
/// Moves files to macOS Trash. Same events as `copy_files` but with `operationType: trash`.

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

Lines changed: 49 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -92,26 +92,53 @@ pub async fn check_rename_validity(
9292
}
9393

9494
/// Renames a file or directory. When `force` is true, proceeds even if the destination exists.
95+
///
96+
/// When `volume_id` is provided and not `"root"`, routes through the Volume trait
97+
/// (needed for MTP and other non-local volumes). Otherwise uses `std::fs::rename`.
9598
#[tauri::command]
96-
pub async fn rename_file(from: String, to: String, force: bool) -> Result<(), IpcError> {
97-
let from_expanded = expand_tilde(&from);
98-
let to_expanded = expand_tilde(&to);
99-
let from_path = PathBuf::from(&from_expanded);
100-
let to_path = PathBuf::from(&to_expanded);
101-
102-
tokio::time::timeout(
103-
Duration::from_secs(5),
104-
tokio::task::spawn_blocking(move || {
105-
if !force && from_path != to_path && std::fs::symlink_metadata(&to_path).is_ok() {
106-
return Err(format!("'{}' already exists", to_path.display()));
107-
}
108-
std::fs::rename(&from_path, &to_path).map_err(|e| format!("Rename failed: {}", e))
109-
}),
110-
)
111-
.await
112-
.map_err(|_| IpcError::timeout())?
113-
.map_err(|e| IpcError::from_err(format!("Task failed: {}", e)))?
114-
.map_err(IpcError::from_err)
99+
pub async fn rename_file(from: String, to: String, force: bool, volume_id: Option<String>) -> Result<(), IpcError> {
100+
let volume_id_str = volume_id.unwrap_or_else(|| "root".to_string());
101+
102+
if volume_id_str != "root" {
103+
// Volume-aware rename (MTP and other non-local volumes)
104+
let volume = crate::file_system::get_volume_manager()
105+
.get(&volume_id_str)
106+
.ok_or_else(|| IpcError::from_err(format!("Volume '{}' not found", volume_id_str)))?;
107+
108+
let from_path = PathBuf::from(&from);
109+
let to_path = PathBuf::from(&to);
110+
111+
tokio::time::timeout(
112+
Duration::from_secs(5),
113+
tokio::task::spawn_blocking(move || {
114+
volume.rename(&from_path, &to_path, force).map_err(|e| format!("{}", e))
115+
}),
116+
)
117+
.await
118+
.map_err(|_| IpcError::timeout())?
119+
.map_err(|e| IpcError::from_err(format!("Task failed: {}", e)))?
120+
.map_err(IpcError::from_err)
121+
} else {
122+
// Local filesystem rename
123+
let from_expanded = expand_tilde(&from);
124+
let to_expanded = expand_tilde(&to);
125+
let from_path = PathBuf::from(&from_expanded);
126+
let to_path = PathBuf::from(&to_expanded);
127+
128+
tokio::time::timeout(
129+
Duration::from_secs(5),
130+
tokio::task::spawn_blocking(move || {
131+
if !force && from_path != to_path && std::fs::symlink_metadata(&to_path).is_ok() {
132+
return Err(format!("'{}' already exists", to_path.display()));
133+
}
134+
std::fs::rename(&from_path, &to_path).map_err(|e| format!("Rename failed: {}", e))
135+
}),
136+
)
137+
.await
138+
.map_err(|_| IpcError::timeout())?
139+
.map_err(|e| IpcError::from_err(format!("Task failed: {}", e)))?
140+
.map_err(IpcError::from_err)
141+
}
115142
}
116143

117144
/// Synchronous permission check implementation.
@@ -430,6 +457,7 @@ mod tests {
430457
old.to_string_lossy().to_string(),
431458
new.to_string_lossy().to_string(),
432459
false,
460+
None,
433461
)
434462
.await;
435463
assert!(result.is_ok());
@@ -450,6 +478,7 @@ mod tests {
450478
old.to_string_lossy().to_string(),
451479
new.to_string_lossy().to_string(),
452480
false,
481+
None,
453482
)
454483
.await;
455484
assert!(result.is_err());
@@ -471,6 +500,7 @@ mod tests {
471500
old.to_string_lossy().to_string(),
472501
new.to_string_lossy().to_string(),
473502
true,
503+
None,
474504
)
475505
.await;
476506
assert!(result.is_ok());

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ network mounts, cross-filesystem moves, and name/path length limits.
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. |
22-
| `delete.rs` | Scan, delete files first, then directories in reverse/deepest-first order. Not rollbackable. |
22+
| `delete.rs` | Scan, delete files first, then directories in reverse/deepest-first order. Not rollbackable. Also contains `delete_volume_files_with_progress` for non-local volumes (MTP): scans via `volume.list_directory()`, deletes via `volume.delete()` per item. |
2323
| `trash.rs` | `move_to_trash_sync()` (macOS: ObjC `trashItemAtURL`; Linux: `trash` crate; 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). |
2424
| `copy_strategy.rs` | Strategy selection per file: network FS → chunked copy; overwrite → temp+rename; macOS → `copyfile(3)`; Linux → `copy_file_range(2)`. |
2525
| `macos_copy.rs` | FFI to macOS `copyfile(3)`. Preserves xattrs, ACLs, resource forks, Finder metadata. Supports APFS `clonefile`. |
@@ -112,6 +112,9 @@ from pre-computed item sizes. Partial failure is supported: if some items fail,
112112

113113
## Key decisions
114114

115+
**Decision**: `delete_files_start` routes to either `delete_files_with_progress` (local, uses `walkdir` + `fs::remove_file`) or `delete_volume_files_with_progress` (non-local, uses `Volume` trait) based on `volume_id`.
116+
**Why**: MTP volumes can't use `walkdir` or `fs::remove_*`. Rather than refactoring the existing local delete to go through the Volume trait (which would add overhead for local ops), we keep the fast local path and add a parallel volume-aware path. Both emit identical events so the frontend progress dialog works unchanged.
117+
115118
**Decision**: Keep `exacl` crate for ACL copy in chunked copies (not custom FFI bindings).
116119
**Why**: `exacl` adds zero new transitive dependencies (all of its deps — `bitflags`, `log`, `scopeguard`, `uuid` — are already in our tree). It provides cross-platform ACL support (macOS, Linux, FreeBSD) and full ACL parsing/manipulation for potential future UI features. The crate appears unmaintained (last release Feb 2024) but ACL APIs are stable and don't change. Our usage is best-effort with graceful fallback — if `exacl` ever breaks, files still copy, they just lose ACLs. MIT licensed (compatible with BSL).
117120

0 commit comments

Comments
 (0)