-
Notifications
You must be signed in to change notification settings - Fork 113
feat(file-explorer): add SFTP copy/cut/paste with confirmation dialog and duplicate handling #402
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
d662f45
a2e4314
1238abd
671a929
9f8060c
837c105
719599f
f0d7374
fe31a46
2415f93
039a823
8b3ed4e
bc042aa
c2accae
7cf731e
80f0080
fa87028
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,156 @@ | ||
| use std::time::Duration; | ||
|
|
||
| #[cfg(not(target_os = "windows"))] | ||
| use std::path::PathBuf; | ||
|
|
||
| const CLIPBOARD_TIMEOUT: Duration = Duration::from_secs(1); | ||
|
|
||
| /// Read the local file paths currently held on the OS clipboard (e.g. files | ||
| /// copied/cut in the system file manager). Unlike `read_clipboard_path_payload`, | ||
| /// this returns ALL paths, not just images, so SFTP paste can upload arbitrary | ||
| /// files. Returns an empty vector when the clipboard holds no file paths. | ||
| #[tauri::command] | ||
| pub async fn read_clipboard_file_paths() -> Vec<String> { | ||
| let result = tokio::time::timeout( | ||
| CLIPBOARD_TIMEOUT, | ||
| tokio::task::spawn_blocking(read_clipboard_file_paths_blocking), | ||
| ) | ||
| .await; | ||
|
|
||
| match result { | ||
| Ok(Ok(paths)) => paths, | ||
| _ => Vec::new(), | ||
| } | ||
| } | ||
|
|
||
| fn read_clipboard_file_paths_blocking() -> Vec<String> { | ||
| #[cfg(target_os = "windows")] | ||
| { | ||
| if let Some(paths) = read_windows_clipboard_file_paths() { | ||
| return paths; | ||
| } | ||
| } | ||
|
|
||
| #[cfg(not(target_os = "windows"))] | ||
| { | ||
| if let Some(paths) = read_clipboard_native_file_paths() { | ||
| return paths; | ||
| } | ||
| if let Some(paths) = read_clipboard_text_file_paths() { | ||
| return paths; | ||
| } | ||
| } | ||
|
|
||
| Vec::new() | ||
| } | ||
|
|
||
| /// Read the native file list from the clipboard. On macOS, Finder puts file | ||
| /// URLs on the pasteboard rather than plain text, so the text parser alone | ||
| /// would never see them. Returns `None` when the clipboard holds no native | ||
| /// file list (e.g. plain text or images). | ||
| #[cfg(not(target_os = "windows"))] | ||
| fn read_clipboard_native_file_paths() -> Option<Vec<String>> { | ||
| let mut clipboard = arboard::Clipboard::new().ok()?; | ||
| match clipboard.get().ok()? { | ||
| arboard::Content::Files(files) => Some( | ||
| files | ||
| .into_iter() | ||
| .filter_map(|file| file.path) | ||
| .filter(|path| path.exists()) | ||
| .map(|path| path.to_string_lossy().to_string()) | ||
| .collect(), | ||
| ), | ||
| _ => None, | ||
| } | ||
| } | ||
|
|
||
| #[cfg(not(target_os = "windows"))] | ||
| fn read_clipboard_text_file_paths() -> Option<Vec<String>> { | ||
| let mut clipboard = arboard::Clipboard::new().ok()?; | ||
| let text = clipboard.get_text().ok()?; | ||
| Some( | ||
| text.lines() | ||
| .map(str::trim) | ||
| .filter(|line| !line.is_empty() && !line.starts_with('#')) | ||
| .filter_map(parse_clipboard_path_text_line) | ||
| .filter(|path| path.exists()) | ||
| .map(|path| path.to_string_lossy().to_string()) | ||
| .collect(), | ||
| ) | ||
| } | ||
|
|
||
| #[cfg(not(target_os = "windows"))] | ||
| fn parse_clipboard_path_text_line(line: &str) -> Option<PathBuf> { | ||
| let unwrapped = line | ||
| .strip_prefix('"') | ||
| .and_then(|value| value.strip_suffix('"')) | ||
| .or_else(|| { | ||
| line.strip_prefix('\'') | ||
| .and_then(|value| value.strip_suffix('\'')) | ||
| }) | ||
| .unwrap_or(line); | ||
|
|
||
| if let Some(uri_path) = unwrapped.strip_prefix("file://") { | ||
| let local_uri_path = match uri_path.strip_prefix("localhost/") { | ||
| Some(path) => format!("/{path}"), | ||
| None => uri_path.to_string(), | ||
| }; | ||
| let decoded = urlencoding::decode(&local_uri_path).ok()?; | ||
| return Some(PathBuf::from(decoded.as_ref())); | ||
|
hellonone marked this conversation as resolved.
|
||
| } | ||
|
|
||
| let path = PathBuf::from(unwrapped); | ||
| if path.is_absolute() { Some(path) } else { None } | ||
| } | ||
|
|
||
| #[cfg(target_os = "windows")] | ||
| fn read_windows_clipboard_file_paths() -> Option<Vec<String>> { | ||
| use windows::Win32::{ | ||
| System::{ | ||
| DataExchange::{CloseClipboard, GetClipboardData, OpenClipboard}, | ||
| Ole::CF_HDROP, | ||
| }, | ||
| UI::Shell::{DragQueryFileW, HDROP}, | ||
| }; | ||
|
|
||
| struct ClipboardGuard; | ||
| impl Drop for ClipboardGuard { | ||
| fn drop(&mut self) { | ||
| unsafe { | ||
| let _ = CloseClipboard(); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| unsafe { | ||
| OpenClipboard(None).ok()?; | ||
| let _guard = ClipboardGuard; | ||
| let handle = GetClipboardData(u32::from(CF_HDROP.0)).ok()?; | ||
| let hdrop = HDROP(handle.0); | ||
| let count = DragQueryFileW(hdrop, u32::MAX, None); | ||
| if count == 0 { | ||
| return Some(Vec::new()); | ||
| } | ||
|
|
||
| let mut paths = Vec::new(); | ||
| for index in 0..count { | ||
| let char_count = DragQueryFileW(hdrop, index, None); | ||
| if char_count == 0 { | ||
| continue; | ||
| } | ||
|
|
||
| let mut buffer = vec![0u16; char_count as usize + 1]; | ||
| let written = DragQueryFileW(hdrop, index, Some(&mut buffer)); | ||
| if written == 0 { | ||
| continue; | ||
| } | ||
|
|
||
| let path = String::from_utf16_lossy(&buffer[..written as usize]); | ||
| if !path.trim().is_empty() { | ||
| paths.push(path); | ||
| } | ||
| } | ||
|
|
||
| Some(paths) | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -72,6 +72,16 @@ pub struct CopyFileEntryRequest { | |
| pub duplicate_strategy_override: Option<String>, | ||
| } | ||
|
|
||
| /// A remote entry to move (cut → paste). Mirrors the frontend clipboard entry | ||
| /// shape so the frontend can pass its in-memory clipboard directly. | ||
| #[derive(Debug, Clone, Deserialize)] | ||
| #[serde(rename_all = "camelCase")] | ||
| pub struct RemoteMoveEntry { | ||
| pub name: String, | ||
| pub path: String, | ||
| pub is_directory: bool, | ||
| } | ||
|
|
||
| fn is_remote_delete_not_found(error: &AppError) -> bool { | ||
| match error { | ||
| AppError::Sftp(SftpError::Status(status)) => status.status_code == StatusCode::NoSuchFile, | ||
|
|
@@ -2083,6 +2093,67 @@ pub async fn delete_remote_file( | |
| Ok(()) | ||
| } | ||
|
|
||
| /// Move (cut → paste) remote entries into `target_dir`. | ||
| /// | ||
| /// Unlike a plain `rename`, this supports both same-session and cross-session | ||
| /// moves and uses copy-then-delete semantics: each entry is first copied with | ||
| /// the same merge/overwrite behaviour as `copy_file_entry` (existing files are | ||
| /// overwritten, existing directories are merged recursively), and only after a | ||
| /// successful copy is the source entry deleted. | ||
| /// | ||
| /// `duplicate_strategy` is forwarded to the copy pipeline so the user's | ||
| /// configured conflict behaviour (skip / rename / ask with an overwrite prompt) | ||
| /// still applies — in particular an "ask" strategy re-prompts before | ||
| /// overwriting an existing file or merging into an existing directory. | ||
| /// | ||
| /// The copy is performed via the existing `copy_file_entry` pipeline so | ||
| /// transfer progress events flow through the regular transfer UI. If any entry | ||
| /// fails to copy, previously copied/deleted entries are NOT rolled back (the | ||
| /// caller pre-validates source existence); the error is returned so the | ||
| /// frontend can surface what happened. | ||
| pub async fn move_remote_entries( | ||
| app: tauri::AppHandle, | ||
| manager: Arc<SessionManager>, | ||
| source_session_id: &str, | ||
| target_session_id: &str, | ||
| target_dir: &str, | ||
| entries: Vec<RemoteMoveEntry>, | ||
| duplicate_strategy: Option<String>, | ||
| ) -> AppResult<()> { | ||
| ensure_local_session_kind(&manager, source_session_id, &CopyEndpointKind::Remote).await?; | ||
| ensure_local_session_kind(&manager, target_session_id, &CopyEndpointKind::Remote).await?; | ||
|
|
||
| for entry in entries { | ||
| let source_path = entry.path.clone(); | ||
| let request = CopyFileEntryRequest { | ||
| source: CopyEndpoint { | ||
| session_id: source_session_id.to_string(), | ||
| kind: CopyEndpointKind::Remote, | ||
| path: source_path.clone(), | ||
| }, | ||
| target: CopyEndpoint { | ||
| session_id: target_session_id.to_string(), | ||
| kind: CopyEndpointKind::Remote, | ||
| path: target_dir.to_string(), | ||
| }, | ||
| file_name: entry.name.clone(), | ||
| is_directory: entry.is_directory, | ||
| transfer_id: None, | ||
| duplicate_strategy_override: duplicate_strategy.clone(), | ||
| }; | ||
| copy_file_entry(app.clone(), manager.clone(), request).await?; | ||
| delete_remote_file( | ||
|
Comment on lines
+2144
to
+2145
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the duplicate strategy is Useful? React with 👍 / 👎. |
||
| manager.clone(), | ||
| source_session_id, | ||
| &source_path, | ||
| None, | ||
| ) | ||
| .await?; | ||
| } | ||
|
|
||
| Ok(()) | ||
| } | ||
|
|
||
| pub async fn rename_remote_file( | ||
| manager: Arc<SessionManager>, | ||
| session_id: &str, | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.