From 09c616de318207ae9700f87e9f49b3c41ac511bb Mon Sep 17 00:00:00 2001 From: Brian Leishman Date: Thu, 12 Mar 2026 13:16:02 -0400 Subject: [PATCH 1/3] fix: handle deleting files from smb shares --- src-tauri/src/commands.rs | 191 +++++++++++++------ src-tauri/src/smb_sidecar/operations.rs | 51 ++++- src/__tests__/unit/store/useAppStore.test.ts | 2 + src/store/useAppStore.ts | 4 +- src/types/index.ts | 1 + 5 files changed, 180 insertions(+), 69 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 494cc7cc..4f8ac309 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -266,6 +266,55 @@ fn normalize_path_for_compare(path: &Path) -> String { value } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TrashStrategy { + SystemTrash, + ProviderTrash, + PermanentDeleteFallback, +} + +fn trash_strategy_for_scheme(scheme: &str) -> TrashStrategy { + match scheme { + "file" => TrashStrategy::SystemTrash, + "gdrive" => TrashStrategy::ProviderTrash, + _ => TrashStrategy::PermanentDeleteFallback, + } +} + +struct DeleteTarget { + provider: crate::locations::ProviderRef, + location: Location, + display_path: String, +} + +fn resolve_delete_targets(paths: Vec) -> Result, String> { + let mut targets = Vec::with_capacity(paths.len()); + + for original in paths { + let (provider, location) = resolve_location(LocationInput::Raw(original))?; + let capabilities = provider.capabilities(&location); + if !capabilities.can_delete { + return Err("Provider does not support deleting items".to_string()); + } + + let display_path = if location.scheme() == "file" { + expand_path(&location.to_path_string())? + .to_string_lossy() + .to_string() + } else { + location.raw().to_string() + }; + + targets.push(DeleteTarget { + provider, + location, + display_path, + }); + } + + Ok(targets) +} + fn cleanup_trash_undo_records(state: &TrashUndoState) { const TTL: Duration = Duration::from_secs(300); const MAX_RECORDS: usize = 10; @@ -714,6 +763,7 @@ pub struct TrashPathsResponse { trashed: Vec, undo_token: Option, fallback_to_permanent: bool, + used_system_trash: bool, } #[derive(Debug, Serialize, Clone)] @@ -1974,22 +2024,55 @@ pub async fn trash_paths(app: AppHandle, paths: Vec) -> Result = Vec::with_capacity(paths.len()); - for original in paths { - let path_buf = expand_path(&original)?; - if !path_buf.exists() { - return Err(format!("Path does not exist: {}", original)); + let targets = resolve_delete_targets(paths)?; + let strategy = trash_strategy_for_scheme(targets[0].location.scheme()); + if targets + .iter() + .any(|target| trash_strategy_for_scheme(target.location.scheme()) != strategy) + { + return Err("Delete selection must come from a single provider type".to_string()); + } + + let original_paths: Vec = targets + .iter() + .map(|target| target.display_path.clone()) + .collect(); + + if strategy == TrashStrategy::PermanentDeleteFallback { + return Ok(TrashPathsResponse { + trashed: Vec::new(), + undo_token: None, + fallback_to_permanent: true, + used_system_trash: false, + }); + } + + if strategy == TrashStrategy::ProviderTrash { + for target in &targets { + target.provider.delete(&target.location).await?; } - expanded.push(path_buf); + + return Ok(TrashPathsResponse { + trashed: original_paths, + undo_token: None, + fallback_to_permanent: false, + used_system_trash: false, + }); } - let original_paths: Vec = expanded + let expanded: Vec = targets .iter() - .map(|path| path.to_string_lossy().to_string()) + .map(|target| PathBuf::from(&target.display_path)) .collect(); + for path_buf in &expanded { + if !path_buf.exists() { + return Err(format!("Path does not exist: {}", path_buf.to_string_lossy())); + } + } let delete_targets: Vec = expanded.clone(); let state = app.state::(); @@ -2018,6 +2101,7 @@ pub async fn trash_paths(app: AppHandle, paths: Vec) -> Result) -> Result = Vec::with_capacity(paths.len()); - for original in paths { - let path_buf = expand_path(&original)?; - if !path_buf.exists() { - return Err(format!("Path does not exist: {}", original)); - } - expanded.push(path_buf); - } - - let total = expanded.len(); - let original_paths: Vec = expanded + let targets = resolve_delete_targets(paths)?; + let total = targets.len(); + let original_paths: Vec = targets .iter() - .map(|path| path.to_string_lossy().to_string()) + .map(|target| target.display_path.clone()) .collect(); - let targets: Vec = expanded.clone(); - let app_handle = app.clone(); - let request_id_clone = request_id.clone(); + for (idx, target) in targets.iter().enumerate() { + let current_path = target.display_path.clone(); + emit_delete_progress_update( + &app, + DeleteProgressUpdatePayload { + request_id: request_id.clone(), + current_path: Some(current_path.clone()), + completed: idx, + total, + finished: false, + error: None, + }, + ); - tauri::async_runtime::spawn_blocking(move || -> Result<(), String> { - for (idx, target) in targets.iter().enumerate() { - let current_path = target.to_string_lossy().to_string(); + if let Err(err) = target.provider.delete(&target.location).await { emit_delete_progress_update( - &app_handle, + &app, DeleteProgressUpdatePayload { - request_id: request_id_clone.clone(), - current_path: Some(current_path.clone()), + request_id: request_id.clone(), + current_path: Some(current_path), completed: idx, total, - finished: false, - error: None, + finished: true, + error: Some(err.clone()), }, ); - - if let Err(err) = delete_file_or_directory(target) { - emit_delete_progress_update( - &app_handle, - DeleteProgressUpdatePayload { - request_id: request_id_clone.clone(), - current_path: Some(current_path), - completed: idx, - total, - finished: true, - error: Some(err.clone()), - }, - ); - return Err(err); - } + return Err(err); } + } - emit_delete_progress_update( - &app_handle, - DeleteProgressUpdatePayload { - request_id: request_id_clone, - current_path: None, - completed: total, - total, - finished: true, - error: None, - }, - ); - - Ok(()) - }) - .await - .map_err(|err| format!("Failed to join delete task: {err}"))??; + emit_delete_progress_update( + &app, + DeleteProgressUpdatePayload { + request_id, + current_path: None, + completed: total, + total, + finished: true, + error: None, + }, + ); Ok(DeletePathsResponse { deleted: original_paths, diff --git a/src-tauri/src/smb_sidecar/operations.rs b/src-tauri/src/smb_sidecar/operations.rs index 20457b1c..879e2010 100644 --- a/src-tauri/src/smb_sidecar/operations.rs +++ b/src-tauri/src/smb_sidecar/operations.rs @@ -64,6 +64,50 @@ fn is_hidden_file(name: &str) -> bool { name.starts_with('.') } +fn join_smb_path(parent: &str, name: &str) -> String { + let trimmed = parent.trim_end_matches('/'); + if trimmed.is_empty() { + format!("/{}", name) + } else { + format!("{}/{}", trimmed, name) + } +} + +fn delete_directory_recursive(client: &SmbClient, path: &str) -> Result<(), (i32, String)> { + let entries = client + .list_dirplus(path) + .map_err(|e| { + let (code, msg) = map_smb_error(&e); + (code, format!("Failed to list directory for deletion: {}", msg)) + })?; + + for entry in entries { + let name = entry.name(); + if name == "." || name == ".." { + continue; + } + + let child_path = join_smb_path(path, name); + if matches!(entry.get_type(), pavao::SmbDirentType::Dir) { + delete_directory_recursive(client, &child_path)?; + } else { + client + .unlink(&child_path) + .map_err(|e| { + let (code, msg) = map_smb_error(&e); + (code, format!("Failed to delete file {}: {}", child_path, msg)) + })?; + } + } + + client + .rmdir(path) + .map_err(|e| { + let (code, msg) = map_smb_error(&e); + (code, format!("Failed to delete directory {}: {}", path, msg)) + }) +} + /// Read directory contents. pub fn read_directory(params: ReadDirectoryParams) -> Result { let _guard = SMB_MUTEX @@ -215,12 +259,7 @@ pub fn delete(params: DeleteParams) -> Result<(), (i32, String)> { })?; if stat.mode.is_dir() { - client - .rmdir(¶ms.path) - .map_err(|e| { - let (code, msg) = map_smb_error(&e); - (code, format!("Failed to delete directory: {}", msg)) - }) + delete_directory_recursive(&client, ¶ms.path) } else { client .unlink(¶ms.path) diff --git a/src/__tests__/unit/store/useAppStore.test.ts b/src/__tests__/unit/store/useAppStore.test.ts index 673d9698..692d61a2 100644 --- a/src/__tests__/unit/store/useAppStore.test.ts +++ b/src/__tests__/unit/store/useAppStore.test.ts @@ -628,6 +628,7 @@ describe('useAppStore', () => { trashed: ['/test/file.txt'], undoToken: 'test-token', fallbackToPermanent: false, + usedSystemTrash: true, }); } if (cmd === 'read_directory') { @@ -659,6 +660,7 @@ describe('useAppStore', () => { trashed: [], undoToken: null, fallbackToPermanent: true, + usedSystemTrash: false, }); } if (cmd === 'delete_paths_permanently') { diff --git a/src/store/useAppStore.ts b/src/store/useAppStore.ts index 0a33366f..5dff7678 100644 --- a/src/store/useAppStore.ts +++ b/src/store/useAppStore.ts @@ -1179,7 +1179,9 @@ export const useAppStore = create((set, get) => ({ const undoToken = response.undoToken; const isMacPlatform = typeof navigator !== 'undefined' && /mac/i.test(navigator.userAgent); const infoMessage = - undoToken || !isMacPlatform ? messageText : `${messageText} Restore via Finder if needed.`; + undoToken || !isMacPlatform || !response.usedSystemTrash + ? messageText + : `${messageText} Restore via Finder if needed.`; if (undoToken) { // Push to undo stack for Cmd+Z support diff --git a/src/types/index.ts b/src/types/index.ts index 261a6734..6f76ba29 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -243,6 +243,7 @@ export interface TrashPathsResponse { trashed: string[]; undoToken?: string; fallbackToPermanent: boolean; + usedSystemTrash: boolean; } export interface UndoTrashResponse { From 5bc28d7bedea37376b77c33d1c327444b424810a Mon Sep 17 00:00:00 2001 From: Brian Leishman Date: Thu, 12 Mar 2026 13:24:25 -0400 Subject: [PATCH 2/3] fix: require modifier for file clipboard shortcuts --- src/App.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 5e8b04b7..b8912f79 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1784,8 +1784,12 @@ function App() { // when NOT in an editable field to do file operations instead. { const keyLc = e.key.toLowerCase(); + const hasClipboardModifier = + !e.altKey && + !e.shiftKey && + ((isMac && e.metaKey && !e.ctrlKey) || (!isMac && e.ctrlKey)); - if (keyLc === 'c' && !inEditable) { + if (hasClipboardModifier && keyLc === 'c' && !inEditable) { const state = useAppStore.getState(); if (state.selectedFiles.length > 0) { e.preventDefault(); @@ -1794,7 +1798,7 @@ function App() { } } - if (keyLc === 'x' && !inEditable) { + if (hasClipboardModifier && keyLc === 'x' && !inEditable) { const state = useAppStore.getState(); if (state.selectedFiles.length > 0) { e.preventDefault(); @@ -1803,7 +1807,7 @@ function App() { } } - if (keyLc === 'v' && !inEditable) { + if (hasClipboardModifier && keyLc === 'v' && !inEditable) { e.preventDefault(); void useAppStore.getState().pasteFiles(); return; From 7a7fc009b5b8b31aa277832dde2d62f7a5c1b9c5 Mon Sep 17 00:00:00 2001 From: Brian Leishman Date: Thu, 12 Mar 2026 13:25:04 -0400 Subject: [PATCH 3/3] chore: format app clipboard shortcut fix --- src/App.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index b8912f79..da3a1bc4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1785,9 +1785,7 @@ function App() { { const keyLc = e.key.toLowerCase(); const hasClipboardModifier = - !e.altKey && - !e.shiftKey && - ((isMac && e.metaKey && !e.ctrlKey) || (!isMac && e.ctrlKey)); + !e.altKey && !e.shiftKey && ((isMac && e.metaKey && !e.ctrlKey) || (!isMac && e.ctrlKey)); if (hasClipboardModifier && keyLc === 'c' && !inEditable) { const state = useAppStore.getState();