From 200ed6a52835e9d66923a2b4f5ad53f420afd24c Mon Sep 17 00:00:00 2001 From: Jaw <53564781+jaw52@users.noreply.github.com> Date: Thu, 29 Oct 2026 18:05:11 +0800 Subject: [PATCH] fix(project): paste files copied in Finder on macOS (#250) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The OS clipboard's file list was only wired up for Windows (CF_HDROP), so on macOS the project view never saw files copied in Finder: Paste stayed disabled and ⌘V did nothing. Read and write the general NSPasteboard's file URLs instead, so Finder's copies paste into the project view and the project view's copies paste in Finder. Closes #246 Co-authored-by: Claude Opus 5.5 --- Cargo.lock | 5 ++ crates/ide-clipboard/Cargo.toml | 7 ++- crates/ide-clipboard/src/lib.rs | 82 +++++++++++++++++++++++++++++++-- 3 files changed, 89 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1da103082..8683066ad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4025,6 +4025,11 @@ dependencies = [ [[package]] name = "ide-clipboard" version = "0.1.24" +dependencies = [ + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", +] [[package]] name = "ide-core" diff --git a/crates/ide-clipboard/Cargo.toml b/crates/ide-clipboard/Cargo.toml index 4f20ecc36..fddf9fecc 100644 --- a/crates/ide-clipboard/Cargo.toml +++ b/crates/ide-clipboard/Cargo.toml @@ -1,8 +1,13 @@ [package] name = "ide-clipboard" -description = "The OS clipboard's file list (Explorer <-> project view): Windows CF_HDROP read/write, no window or message loop" +description = "The OS clipboard's file list (Explorer <-> project view): Windows CF_HDROP or macOS NSPasteboard file URLs, no window or message loop" version.workspace = true edition.workspace = true license.workspace = true [dependencies] + +[target.'cfg(target_os = "macos")'.dependencies] +objc2 = { version = "0.6", default-features = false, features = ["std"] } +objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSArray", "NSDictionary", "NSEnumerator", "NSObject", "NSString", "NSURL", "NSValue"] } +objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSPasteboard"] } diff --git a/crates/ide-clipboard/src/lib.rs b/crates/ide-clipboard/src/lib.rs index 04dd050c2..1c64c30b7 100644 --- a/crates/ide-clipboard/src/lib.rs +++ b/crates/ide-clipboard/src/lib.rs @@ -7,9 +7,12 @@ //! in full rather than rendered on demand, so no window or message loop is //! needed and the list survives after `CloseClipboard`. //! -//! Other platforms keep file lists elsewhere (`public.file-url` on macOS, -//! `text/uri-list` on X11) and are not wired up: [`files`] is empty and -//! [`set_files`] fails there. +//! macOS keeps one `public.file-url` item per file on the general +//! `NSPasteboard`; `NSURL` reads and writes them, so Finder's copies paste here +//! and ours paste in Finder. +//! +//! Other platforms (`text/uri-list` on X11) are not wired up: [`files`] is +//! empty and [`set_files`] fails there. use std::path::PathBuf; @@ -218,7 +221,78 @@ mod imp { } } -#[cfg(not(windows))] +#[cfg(target_os = "macos")] +mod imp { + use objc2::ClassType; + use objc2::rc::{Retained, autoreleasepool}; + use objc2::runtime::{AnyClass, AnyObject, ProtocolObject}; + use objc2_app_kit::{ + NSPasteboard, NSPasteboardReadingOptionKey, NSPasteboardURLReadingFileURLsOnlyKey, NSPasteboardWriting, + }; + use objc2_foundation::{NSArray, NSDictionary, NSNumber, NSURL}; + use std::path::PathBuf; + + /// `NSURL` as the one class to read, limited to file URLs: a web link the + /// user copied is not a file to paste. + fn read_file_urls( + f: impl FnOnce(&NSPasteboard, &NSArray, &NSDictionary) -> R, + ) -> R { + autoreleasepool(|_| { + let board = NSPasteboard::generalPasteboard(); + let classes = NSArray::from_slice(&[NSURL::class()]); + let yes = NSNumber::new_bool(true); + // SAFETY: the key is an AppKit constant, valid for the process' lifetime. + let key = unsafe { NSPasteboardURLReadingFileURLsOnlyKey }; + let options = NSDictionary::from_slices(&[key], &[yes.as_ref() as &AnyObject]); + f(&board, &classes, &options) + }) + } + + pub fn has_files() -> bool { + // SAFETY: the class list holds `NSURL`, which reads from pasteboards, + // and the option takes an `NSNumber`. + read_file_urls(|board, classes, options| unsafe { + board.canReadObjectForClasses_options(classes, Some(options)) + }) + } + + pub fn files() -> Vec { + read_file_urls(|board, classes, options| { + // SAFETY: as in `has_files`; every object read for `NSURL` is one. + let Some(objects) = (unsafe { board.readObjectsForClasses_options(classes, Some(options)) }) else { + return Vec::new(); + }; + objects + .iter() + .filter_map(|object| object.downcast::().ok()) + .filter_map(|url| url.to_file_path()) + // Finder ends a folder's URL in `/`; the project view names it without. + .map(|path| path.components().collect()) + .collect() + }) + } + + pub fn set_files(paths: &[PathBuf]) -> bool { + autoreleasepool(|_| { + // Folders get a directory URL (ending in `/`), as Finder writes them. + let urls: Vec> = paths + .iter() + .filter_map(|p| if p.is_dir() { NSURL::from_directory_path(p) } else { NSURL::from_file_path(p) }) + .collect(); + if urls.is_empty() { + return false; + } + let writers: Vec<&ProtocolObject> = + urls.iter().map(|url| ProtocolObject::from_ref(&**url)).collect(); + let board = NSPasteboard::generalPasteboard(); + // Replacing the list also drops the text the user copied before. + board.clearContents(); + board.writeObjects(&NSArray::from_slice(&writers)) + }) + } +} + +#[cfg(not(any(windows, target_os = "macos")))] mod imp { use std::path::PathBuf;