Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
d662f45
feat(clipboard): add command to read file paths from OS clipboard
hellonone Aug 9, 2026
a2e4314
feat(file-explorer): add SFTP clipboard with last-copy-wins detection
hellonone Aug 9, 2026
1238abd
feat(file-explorer): register copy, cut and paste shortcuts
hellonone Aug 9, 2026
671a929
feat(file-explorer): add paste confirmation dialog
hellonone Aug 9, 2026
9f8060c
feat(file-explorer): resolve duplicates when moving pasted files
hellonone Aug 9, 2026
837c105
feat(file-explorer): implement paste handler with confirmation and du…
hellonone Aug 9, 2026
719599f
chore(i18n): add file explorer copy, cut and paste localization strings
hellonone Aug 9, 2026
f0d7374
refactor(file-explorer): simplify paste handling and dedupe copy/cut …
hellonone Aug 9, 2026
fe31a46
feat(file-explorer): validate paste sources still exist before enqueuing
hellonone Aug 9, 2026
2415f93
chore(i18n): add paste source missing localization strings
hellonone Aug 9, 2026
039a823
fix(file-explorer): let focused dialog button handle Enter in paste c…
hellonone Aug 10, 2026
8b3ed4e
fix(clipboard): preserve root slash when parsing localhost file URIs
hellonone Aug 10, 2026
bc042aa
fix(clipboard): read native file list from clipboard on macOS
hellonone Aug 10, 2026
c2accae
feat(file-explorer): move cut-paste via copy-then-delete backend command
hellonone Aug 10, 2026
7cf731e
chore(i18n): remove unused paste cross-session move copy
hellonone Aug 10, 2026
80f0080
Merge remote-tracking branch 'upstream/main' into feat/sftp-paste
hellonone Aug 11, 2026
fa87028
Merge branch 'nyakang:main' into feat/sftp-paste
hellonone Aug 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 156 additions & 0 deletions src-tauri/src/cmd/file_clipboard.rs
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()?;
Comment thread
hellonone marked this conversation as resolved.
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()));
Comment thread
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)
}
}
1 change: 1 addition & 0 deletions src-tauri/src/cmd/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ pub mod connection;
pub mod credential;
pub mod docker;
pub mod external_open;
pub mod file_clipboard;
pub mod gpu;
pub mod importer;
pub mod local_fs;
Expand Down
22 changes: 22 additions & 0 deletions src-tauri/src/cmd/sftp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,28 @@ pub async fn copy_file_entry(
sftp::copy_file_entry(app, state.inner().clone(), request).await
}

#[tauri::command]
pub async fn move_remote_entries(
app: tauri::AppHandle,
state: tauri::State<'_, Arc<SessionManager>>,
source_session_id: String,
target_session_id: String,
target_dir: String,
entries: Vec<sftp::RemoteMoveEntry>,
duplicate_strategy: Option<String>,
) -> AppResult<()> {
sftp::move_remote_entries(
app,
state.inner().clone(),
&source_session_id,
&target_session_id,
&target_dir,
entries,
duplicate_strategy,
)
.await
}

#[tauri::command]
pub async fn pause_transfer(app: tauri::AppHandle, transfer_id: String) -> AppResult<()> {
sftp::pause_transfer(app, &transfer_id).await
Expand Down
71 changes: 71 additions & 0 deletions src-tauri/src/core/sftp/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Do not delete sources when duplicate copy is skipped

When the duplicate strategy is skip, or an ask prompt is answered with Skip, copy_file_entry emits a cancelled transfer but returns Ok(()); this code then immediately deletes the source anyway. A cut-paste onto an existing name can therefore discard the cut file without creating a new copy, so the copy pipeline must report whether it actually copied before deletion is allowed.

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,
Expand Down
2 changes: 2 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ pub fn run() {
cmd::clipboard::write_clipboard_text,
cmd::clipboard::read_clipboard_path_payload,
cmd::clipboard::upload_clipboard_image_to_ssh,
cmd::file_clipboard::read_clipboard_file_paths,
cmd::log::append_frontend_logs,
cmd::log::export_diagnostics,
cmd::note::list_note_tree,
Expand Down Expand Up @@ -253,6 +254,7 @@ pub fn run() {
cmd::sftp::download_remote_directory,
cmd::sftp::upload_local_directory,
cmd::sftp::copy_file_entry,
cmd::sftp::move_remote_entries,
cmd::sftp::pause_transfer,
cmd::sftp::resume_transfer,
cmd::sftp::cancel_transfer,
Expand Down
Loading