Skip to content

Commit 0dc2953

Browse files
committed
Clipboard: Add Cmd+C/V/X for files, Finder interop
- New `clipboard/` Rust module: NSPasteboard FFI via objc2 (write/read file URLs + plain text), in-process cut state tracking - Four Tauri commands: `copy_files_to_clipboard`, `cut_files_to_clipboard`, `read_clipboard_files`, `clear_clipboard_cut_state` - Frontend: command registry entries, handlers in `+page.svelte` with `activeElement` routing (text input → native clipboard, file list → file clipboard), `DualPaneExplorer` methods, `startTransferProgress` for direct paste into `TransferProgressDialog` - Menu: replaced PredefinedMenuItem cut/copy/paste with custom MenuItems routing through `execute-command` dispatch, added "Move here" (⌥⌘V) - Cmd+X sets internal cut flag; Cmd+V after cut moves files; stale cut state auto-cleared if clipboard changes between cut and paste - MTP paths excluded with notification - Linux: stubs return errors, menu items added with Ctrl accelerators
1 parent aea30b4 commit 0dc2953

19 files changed

Lines changed: 1209 additions & 31 deletions

File tree

apps/desktop/src-tauri/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ objc2-foundation = { version = "0.3", features = [
125125
] }
126126
objc2-app-kit = { version = "0.3", features = [
127127
"NSDragging", "NSDraggingItem", "NSImage", "NSColor", "NSColorSpace",
128-
"NSApplication", "NSMenu", "NSMenuItem", "NSRunningApplication",
128+
"NSApplication", "NSMenu", "NSMenuItem", "NSPasteboard", "NSRunningApplication",
129129
] }
130130
block2 = "0.6"
131131
security-framework = "3.2"
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
//! Clipboard file operations for copy/cut/paste workflows.
2+
3+
#[cfg(target_os = "macos")]
4+
mod pasteboard;
5+
mod state;
6+
7+
pub use state::{clear_cut_state, get_cut_state, set_cut_state};
8+
9+
#[cfg(target_os = "macos")]
10+
pub use pasteboard::{read_file_urls_from_clipboard, write_file_urls_to_clipboard};
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
//! macOS NSPasteboard FFI for file URL clipboard operations.
2+
//!
3+
//! All functions assume they are called on the main thread. Callers must use
4+
//! `app.run_on_main_thread()` when invoking from async Tauri commands.
5+
6+
use std::path::PathBuf;
7+
8+
use objc2::ClassType;
9+
use objc2::msg_send;
10+
use objc2::rc::Retained;
11+
use objc2::runtime::AnyObject;
12+
use objc2_app_kit::{
13+
NSPasteboard, NSPasteboardReadingOptionKey, NSPasteboardTypeString, NSPasteboardURLReadingFileURLsOnlyKey,
14+
};
15+
use objc2_foundation::{NSArray, NSDictionary, NSString, NSURL};
16+
17+
/// Writes file URLs to the system pasteboard.
18+
///
19+
/// Places both file URL items (for Finder-compatible paste) and plain-text
20+
/// newline-separated paths (for pasting into text editors).
21+
pub fn write_file_urls_to_clipboard(paths: &[PathBuf]) -> Result<(), String> {
22+
if paths.is_empty() {
23+
return Err("No paths to write to clipboard".to_string());
24+
}
25+
26+
let pasteboard = NSPasteboard::generalPasteboard();
27+
28+
// Build NSURL objects for each path
29+
let urls: Vec<Retained<NSURL>> = paths
30+
.iter()
31+
.map(|p| {
32+
let ns_path = NSString::from_str(&p.to_string_lossy());
33+
NSURL::fileURLWithPath(&ns_path)
34+
})
35+
.collect();
36+
37+
// Clear existing contents
38+
pasteboard.clearContents();
39+
40+
// Write file URLs via writeObjects. NSURL conforms to NSPasteboardWriting,
41+
// so we use msg_send! to pass the array without ProtocolObject generic juggling.
42+
let url_refs: Vec<&NSURL> = urls.iter().map(|u| &**u).collect();
43+
let url_array = NSArray::from_slice(&url_refs);
44+
let success: bool = unsafe { msg_send![&pasteboard, writeObjects: &*url_array] };
45+
if !success {
46+
return Err("NSPasteboard writeObjects returned false".to_string());
47+
}
48+
49+
// Also write plain-text paths (newline-separated) so pasting into text editors works
50+
let text = paths.iter().map(|p| p.to_string_lossy()).collect::<Vec<_>>().join("\n");
51+
let ns_text = NSString::from_str(&text);
52+
let pasteboard_type = unsafe { NSPasteboardTypeString };
53+
pasteboard.setString_forType(&ns_text, pasteboard_type);
54+
55+
log::info!("Wrote {} file URL(s) to clipboard", paths.len());
56+
Ok(())
57+
}
58+
59+
/// Reads file URLs from the system pasteboard.
60+
///
61+
/// Uses `readObjectsForClasses:options:` with `NSURL` and `fileURLsOnly` to retrieve
62+
/// only local file URLs (not remote HTTP URLs).
63+
pub fn read_file_urls_from_clipboard() -> Result<Vec<PathBuf>, String> {
64+
let pasteboard = NSPasteboard::generalPasteboard();
65+
66+
// Build class array containing NSURL's class
67+
let nsurl_class = NSURL::class();
68+
let class_array: Retained<NSArray<objc2::runtime::AnyClass>> = unsafe {
69+
// NSArray<AnyClass> from a single class pointer
70+
msg_send![
71+
objc2::runtime::AnyClass::get(c"NSArray").ok_or("NSArray class not found")?,
72+
arrayWithObject: nsurl_class,
73+
]
74+
};
75+
76+
// Options: fileURLsOnly = true
77+
let file_urls_only_key = unsafe { NSPasteboardURLReadingFileURLsOnlyKey };
78+
let yes_value: Retained<AnyObject> = unsafe {
79+
let cls = objc2::runtime::AnyClass::get(c"NSNumber").ok_or("NSNumber class not found")?;
80+
let obj: *mut AnyObject = msg_send![cls, numberWithBool: true];
81+
Retained::retain(obj).ok_or("Couldn't create NSNumber")?
82+
};
83+
84+
let options: Retained<NSDictionary<NSPasteboardReadingOptionKey, AnyObject>> = unsafe {
85+
msg_send![
86+
objc2::runtime::AnyClass::get(c"NSDictionary").ok_or("NSDictionary class not found")?,
87+
dictionaryWithObject: &*yes_value,
88+
forKey: file_urls_only_key,
89+
]
90+
};
91+
92+
let objects = unsafe { pasteboard.readObjectsForClasses_options(&class_array, Some(&options)) };
93+
94+
let Some(objects) = objects else {
95+
return Ok(Vec::new());
96+
};
97+
98+
let count = objects.len();
99+
let mut paths = Vec::with_capacity(count);
100+
for i in 0..count {
101+
let obj: &AnyObject = unsafe { msg_send![&objects, objectAtIndex: i] };
102+
let path_str: Option<Retained<NSString>> = unsafe { msg_send![obj, path] };
103+
if let Some(ns_str) = path_str {
104+
paths.push(PathBuf::from(ns_str.to_string()));
105+
}
106+
}
107+
108+
Ok(paths)
109+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
//! In-process cut state tracking.
2+
//!
3+
//! When the user cuts files, we write them to the system clipboard (same as copy)
4+
//! and also record the source paths here. On paste, we check whether the clipboard
5+
//! still matches the cut set to decide between copy and move semantics.
6+
7+
use std::path::PathBuf;
8+
use std::sync::{LazyLock, RwLock};
9+
10+
struct CutState {
11+
source_paths: Vec<PathBuf>,
12+
}
13+
14+
static CUT_STATE: LazyLock<RwLock<Option<CutState>>> = LazyLock::new(|| RwLock::new(None));
15+
16+
pub fn set_cut_state(paths: Vec<PathBuf>) {
17+
let mut guard = CUT_STATE.write().unwrap_or_else(|e| e.into_inner());
18+
*guard = Some(CutState { source_paths: paths });
19+
}
20+
21+
pub fn clear_cut_state() {
22+
let mut guard = CUT_STATE.write().unwrap_or_else(|e| e.into_inner());
23+
*guard = None;
24+
}
25+
26+
pub fn get_cut_state() -> Option<Vec<PathBuf>> {
27+
let guard = CUT_STATE.read().unwrap_or_else(|e| e.into_inner());
28+
guard.as_ref().map(|s| s.source_paths.clone())
29+
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ immediately to business-logic modules. No significant logic lives here.
2222
| `settings.rs` | Settings | Port availability check, watcher debounce setting, menu accelerator updates |
2323
| `licensing.rs` | Licensing | Status query, activation, expiry, reminder, key validation |
2424
| `indexing.rs` | Drive index | `start_drive_index`, `stop_drive_index`, `get_index_status`, `get_dir_stats`, `get_dir_stats_batch`, `prioritize_dir`, `cancel_nav_priority`, `clear_drive_index`, `set_indexing_enabled`. Uses `State<IndexManagerState>`. |
25+
| `clipboard.rs` | Clipboard file ops | `copy_files_to_clipboard`, `cut_files_to_clipboard`, `read_clipboard_files`, `clear_clipboard_cut_state`. macOS uses NSPasteboard via `clipboard::pasteboard`; non-macOS stubs return errors. |
2526
| `sync_status.rs` | Cloud sync status | `get_sync_status` — macOS delegates to `file_system::sync_status`; non-macOS returns empty map via `#[cfg]` on the function itself (not the module). |
2627

2728
## Key decisions
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
//! Tauri commands for clipboard file operations (copy/cut/paste).
2+
3+
use std::collections::HashSet;
4+
use std::path::PathBuf;
5+
6+
#[cfg(target_os = "macos")]
7+
use crate::file_system::get_paths_at_indices as ops_get_paths_at_indices;
8+
9+
use crate::clipboard;
10+
11+
#[derive(serde::Serialize)]
12+
#[serde(rename_all = "camelCase")]
13+
pub struct ClipboardReadResult {
14+
paths: Vec<String>,
15+
is_cut: bool,
16+
}
17+
18+
/// Resolves selected file paths and writes them to the system clipboard.
19+
/// Clears any existing cut state (this is a copy, not a cut).
20+
#[cfg(target_os = "macos")]
21+
#[tauri::command]
22+
pub async fn copy_files_to_clipboard(
23+
app: tauri::AppHandle,
24+
listing_id: String,
25+
selected_indices: Vec<usize>,
26+
cursor_index: usize,
27+
has_parent: bool,
28+
include_hidden: bool,
29+
) -> Result<usize, String> {
30+
let indices = resolve_indices(&selected_indices, cursor_index, has_parent);
31+
let paths = ops_get_paths_at_indices(&listing_id, &indices, include_hidden, has_parent)?;
32+
33+
if paths.is_empty() {
34+
return Err("No files to copy".to_string());
35+
}
36+
37+
let count = paths.len();
38+
39+
// Write to pasteboard on the main thread (NSPasteboard requires it)
40+
let (tx, rx) = std::sync::mpsc::channel();
41+
app.run_on_main_thread(move || {
42+
let result = clipboard::write_file_urls_to_clipboard(&paths);
43+
let _ = tx.send(result);
44+
})
45+
.map_err(|e| format!("Couldn't run on main thread: {e}"))?;
46+
47+
rx.recv()
48+
.map_err(|e| format!("Couldn't receive pasteboard result: {e}"))??;
49+
50+
clipboard::clear_cut_state();
51+
52+
Ok(count)
53+
}
54+
55+
/// Resolves selected file paths, writes them to the system clipboard, and marks them as cut.
56+
/// On paste, files will be moved instead of copied.
57+
#[cfg(target_os = "macos")]
58+
#[tauri::command]
59+
pub async fn cut_files_to_clipboard(
60+
app: tauri::AppHandle,
61+
listing_id: String,
62+
selected_indices: Vec<usize>,
63+
cursor_index: usize,
64+
has_parent: bool,
65+
include_hidden: bool,
66+
) -> Result<usize, String> {
67+
let indices = resolve_indices(&selected_indices, cursor_index, has_parent);
68+
let paths = ops_get_paths_at_indices(&listing_id, &indices, include_hidden, has_parent)?;
69+
70+
if paths.is_empty() {
71+
return Err("No files to cut".to_string());
72+
}
73+
74+
let count = paths.len();
75+
let cut_paths = paths.clone();
76+
77+
// Write to pasteboard on the main thread
78+
let (tx, rx) = std::sync::mpsc::channel();
79+
app.run_on_main_thread(move || {
80+
let result = clipboard::write_file_urls_to_clipboard(&paths);
81+
let _ = tx.send(result);
82+
})
83+
.map_err(|e| format!("Couldn't run on main thread: {e}"))?;
84+
85+
rx.recv()
86+
.map_err(|e| format!("Couldn't receive pasteboard result: {e}"))??;
87+
88+
clipboard::set_cut_state(cut_paths);
89+
90+
Ok(count)
91+
}
92+
93+
/// Reads file URLs from the system clipboard and checks whether they were cut.
94+
///
95+
/// If the clipboard contents no longer match the recorded cut state (the user copied
96+
/// something else), the stale cut state is automatically cleared.
97+
#[cfg(target_os = "macos")]
98+
#[tauri::command]
99+
pub async fn read_clipboard_files(app: tauri::AppHandle) -> Result<ClipboardReadResult, String> {
100+
// Read from pasteboard on the main thread
101+
let (tx, rx) = std::sync::mpsc::channel();
102+
app.run_on_main_thread(move || {
103+
let result = clipboard::read_file_urls_from_clipboard();
104+
let _ = tx.send(result);
105+
})
106+
.map_err(|e| format!("Couldn't run on main thread: {e}"))?;
107+
108+
let clipboard_paths = rx
109+
.recv()
110+
.map_err(|e| format!("Couldn't receive pasteboard result: {e}"))??;
111+
112+
// Check cut state: if set, verify paths match (order-insensitive)
113+
let is_cut = if let Some(cut_paths) = clipboard::get_cut_state() {
114+
let clipboard_set: HashSet<&PathBuf> = clipboard_paths.iter().collect();
115+
let cut_set: HashSet<&PathBuf> = cut_paths.iter().collect();
116+
117+
if clipboard_set == cut_set {
118+
true
119+
} else {
120+
// Clipboard changed since the cut -- clear stale state
121+
clipboard::clear_cut_state();
122+
false
123+
}
124+
} else {
125+
false
126+
};
127+
128+
let paths: Vec<String> = clipboard_paths
129+
.iter()
130+
.map(|p| p.to_string_lossy().into_owned())
131+
.collect();
132+
133+
Ok(ClipboardReadResult { paths, is_cut })
134+
}
135+
136+
/// Clears the in-process cut state without touching the system clipboard.
137+
#[tauri::command]
138+
pub fn clear_clipboard_cut_state() {
139+
clipboard::clear_cut_state();
140+
}
141+
142+
// --- Linux stubs ---
143+
144+
#[cfg(not(target_os = "macos"))]
145+
#[tauri::command]
146+
pub async fn copy_files_to_clipboard(
147+
_app: tauri::AppHandle,
148+
_listing_id: String,
149+
_selected_indices: Vec<usize>,
150+
_cursor_index: usize,
151+
_has_parent: bool,
152+
_include_hidden: bool,
153+
) -> Result<usize, String> {
154+
Err("Clipboard file operations are not yet supported on this platform".to_string())
155+
}
156+
157+
#[cfg(not(target_os = "macos"))]
158+
#[tauri::command]
159+
pub async fn cut_files_to_clipboard(
160+
_app: tauri::AppHandle,
161+
_listing_id: String,
162+
_selected_indices: Vec<usize>,
163+
_cursor_index: usize,
164+
_has_parent: bool,
165+
_include_hidden: bool,
166+
) -> Result<usize, String> {
167+
Err("Clipboard file operations are not yet supported on this platform".to_string())
168+
}
169+
170+
#[cfg(not(target_os = "macos"))]
171+
#[tauri::command]
172+
pub async fn read_clipboard_files(_app: tauri::AppHandle) -> Result<ClipboardReadResult, String> {
173+
Err("Clipboard file operations are not yet supported on this platform".to_string())
174+
}
175+
176+
// --- Helpers ---
177+
178+
/// When no files are selected, falls back to the cursor index (adjusting for the ".." entry).
179+
fn resolve_indices(selected_indices: &[usize], cursor_index: usize, has_parent: bool) -> Vec<usize> {
180+
if !selected_indices.is_empty() {
181+
return selected_indices.to_vec();
182+
}
183+
184+
// Nothing selected -- use the cursor position.
185+
// If the cursor is on ".." (index 0 with has_parent), skip it.
186+
if has_parent && cursor_index == 0 {
187+
return Vec::new();
188+
}
189+
190+
vec![cursor_index]
191+
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
//! Tauri commands module.
22
3+
pub mod clipboard;
34
pub mod e2e;
45
pub mod file_system;
56
pub mod file_viewer;

apps/desktop/src-tauri/src/lib.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ mod accent_color;
6767
mod accent_color_linux;
6868
mod ai;
6969
pub mod benchmark;
70+
mod clipboard;
7071
mod commands;
7172
pub mod config;
7273
#[cfg(target_os = "macos")]
@@ -777,6 +778,11 @@ pub fn run() {
777778
commands::indexing::set_indexing_enabled,
778779
// E2E test support
779780
commands::e2e::get_e2e_start_path,
781+
// Clipboard file operations
782+
commands::clipboard::copy_files_to_clipboard,
783+
commands::clipboard::cut_files_to_clipboard,
784+
commands::clipboard::read_clipboard_files,
785+
commands::clipboard::clear_clipboard_cut_state,
780786
])
781787
.on_window_event(|window, event| {
782788
// When the main window is closed, quit the entire app (including settings/debug/viewer windows)

0 commit comments

Comments
 (0)