From d265b32548f0335a5b944ed1c371e9f0afe6a1d0 Mon Sep 17 00:00:00 2001 From: David <88090072+davidg0022@users.noreply.github.com> Date: Wed, 18 Mar 2026 21:10:42 +0200 Subject: [PATCH] project_panel: Add support for pasting external files on macOS (#49367) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of #29026 ## Summary https://github.com/user-attachments/assets/35b4f969-1fcf-45f4-88cd-cbc27ad9696e macOS Finder places file paths on the system pasteboard using `NSFilenamesPboardType` when files are copied. Previously, the project panel only supported its own internal clipboard for copy/cut/paste operations and ignored system clipboard content entirely. This meant that copying files in Finder and pasting them in Zed's project panel did nothing. This PR adds support for reading file paths from the macOS system pasteboard, enabling a natural workflow where users can copy files in Finder (or other file managers) and paste them directly into Zed's project panel. > **Note:** Pasting files from a system file manager currently only works on macOS. The project panel changes are cross-platform, but the clipboard reading of file paths (`ExternalPaths`) is only implemented in the macOS pasteboard. Windows and Linux would need equivalent changes in their respective platform clipboard implementations to support this. Copying/cutting files from the project panel to the system clipboard as plain text works on all platforms. ### Changes **`crates/gpui/src/platform/mac/pasteboard.rs`** - Read `NSFilenamesPboardType` from the system pasteboard and surface file paths as `ClipboardEntry::ExternalPaths` - Check for file paths before plain text, since Finder puts both types on the pasteboard (without this priority, file paths would be returned as plain text strings) - Extract the string-reading logic into `read_string_from_pasteboard()` to allow reuse **`crates/project_panel/src/project_panel.rs`** - On paste, check the system clipboard for external file paths and use the existing `drop_external_files` mechanism to copy them into the project - On copy/cut, write the selected entries' absolute paths to the system clipboard so other apps can consume them - Update the "Paste" context menu item to also be enabled when the system clipboard contains file paths, not just when the internal clipboard has entries ## Test plan - [ ] Copy one or more files in Finder, paste in the project panel — files should be copied into the selected directory - [ ] Copy files within the project panel, paste — existing internal copy/paste behavior is preserved - [ ] Cut files within the project panel, paste — existing internal cut/paste behavior is preserved - [ ] Copy files in the project panel, paste in Finder or another app — paths are available as plain text - [ ] Right-click context menu shows "Paste" enabled when system clipboard has file paths - [ ] Right-click context menu shows "Paste" disabled when both internal and system clipboards are empty Release Notes: - Added support for pasting files from Finder (and other file managers) into the project panel via the system clipboard (macOS only). Copying or cutting files in the project panel now also writes their paths to the system clipboard for use in other apps. --------- Co-authored-by: Smit Barmase --- crates/gpui_macos/src/pasteboard.rs | 239 ++++++++++++++++-- crates/project_panel/src/project_panel.rs | 103 ++++++-- .../project_panel/src/project_panel_tests.rs | 111 ++++++++ 3 files changed, 409 insertions(+), 44 deletions(-) diff --git a/crates/gpui_macos/src/pasteboard.rs b/crates/gpui_macos/src/pasteboard.rs index aceb635194402cdb203aed0f27aae78fa42be32d..d8b7f5627ddc44bea867132c91216b00729488d9 100644 --- a/crates/gpui_macos/src/pasteboard.rs +++ b/crates/gpui_macos/src/pasteboard.rs @@ -1,16 +1,23 @@ use core::slice; -use std::ffi::c_void; +use std::ffi::{CStr, c_void}; +use std::path::PathBuf; use cocoa::{ - appkit::{NSPasteboard, NSPasteboardTypePNG, NSPasteboardTypeString, NSPasteboardTypeTIFF}, + appkit::{ + NSFilenamesPboardType, NSPasteboard, NSPasteboardTypePNG, NSPasteboardTypeString, + NSPasteboardTypeTIFF, + }, base::{id, nil}, - foundation::NSData, + foundation::{NSArray, NSData, NSFastEnumeration, NSString}, }; use objc::{msg_send, runtime::Object, sel, sel_impl}; +use smallvec::SmallVec; use strum::IntoEnumIterator as _; use crate::ns_string; -use gpui::{ClipboardEntry, ClipboardItem, ClipboardString, Image, ImageFormat, hash}; +use gpui::{ + ClipboardEntry, ClipboardItem, ClipboardString, ExternalPaths, Image, ImageFormat, hash, +}; pub struct Pasteboard { inner: id, @@ -41,28 +48,37 @@ impl Pasteboard { } pub fn read(&self) -> Option { - // First, see if it's a string. unsafe { - let pasteboard_types: id = self.inner.types(); - let string_type: id = ns_string("public.utf8-plain-text"); + // Check for file paths first + let filenames = NSPasteboard::propertyListForType(self.inner, NSFilenamesPboardType); + if filenames != nil && NSArray::count(filenames) > 0 { + let mut paths = SmallVec::new(); + for file in filenames.iter() { + let f = NSString::UTF8String(file); + let path = CStr::from_ptr(f).to_string_lossy().into_owned(); + paths.push(PathBuf::from(path)); + } + if !paths.is_empty() { + let mut entries = vec![ClipboardEntry::ExternalPaths(ExternalPaths(paths))]; + + // Also include the string representation so text editors can + // paste the path as text. + if let Some(string_item) = self.read_string_from_pasteboard() { + entries.push(string_item); + } - if msg_send![pasteboard_types, containsObject: string_type] { - let data = self.inner.dataForType(string_type); - if data == nil { - return None; - } else if data.bytes().is_null() { - // https://developer.apple.com/documentation/foundation/nsdata/1410616-bytes?language=objc - // "If the length of the NSData object is 0, this property returns nil." - return Some(self.read_string(&[])); - } else { - let bytes = - slice::from_raw_parts(data.bytes() as *mut u8, data.length() as usize); - - return Some(self.read_string(bytes)); + return Some(ClipboardItem { entries }); } } - // If it wasn't a string, try the various supported image types. + // Next, check for a plain string. + if let Some(string_entry) = self.read_string_from_pasteboard() { + return Some(ClipboardItem { + entries: vec![string_entry], + }); + } + + // Finally, try the various supported image types. for format in ImageFormat::iter() { if let Some(item) = self.read_image(format) { return Some(item); @@ -70,7 +86,6 @@ impl Pasteboard { } } - // If it wasn't a string or a supported image type, give up. None } @@ -94,8 +109,26 @@ impl Pasteboard { } } - fn read_string(&self, text_bytes: &[u8]) -> ClipboardItem { + unsafe fn read_string_from_pasteboard(&self) -> Option { unsafe { + let pasteboard_types: id = self.inner.types(); + let string_type: id = ns_string("public.utf8-plain-text"); + + if !msg_send![pasteboard_types, containsObject: string_type] { + return None; + } + + let data = self.inner.dataForType(string_type); + let text_bytes: &[u8] = if data == nil { + return None; + } else if data.bytes().is_null() { + // https://developer.apple.com/documentation/foundation/nsdata/1410616-bytes?language=objc + // "If the length of the NSData object is 0, this property returns nil." + &[] + } else { + slice::from_raw_parts(data.bytes() as *mut u8, data.length() as usize) + }; + let text = String::from_utf8_lossy(text_bytes).to_string(); let metadata = self .data_for_type(self.text_hash_type) @@ -111,9 +144,7 @@ impl Pasteboard { } }); - ClipboardItem { - entries: vec![ClipboardEntry::String(ClipboardString { text, metadata })], - } + Some(ClipboardEntry::String(ClipboardString { text, metadata })) } } @@ -300,12 +331,44 @@ impl UTType { #[cfg(test)] mod tests { - use cocoa::{appkit::NSPasteboardTypeString, foundation::NSData}; + use cocoa::{ + appkit::{NSFilenamesPboardType, NSPasteboard, NSPasteboardTypeString}, + base::{id, nil}, + foundation::{NSArray, NSData}, + }; + use std::ffi::c_void; - use gpui::{ClipboardEntry, ClipboardItem, ClipboardString}; + use gpui::{ClipboardEntry, ClipboardItem, ClipboardString, ImageFormat}; use super::*; + unsafe fn simulate_external_file_copy(pasteboard: &Pasteboard, paths: &[&str]) { + unsafe { + let ns_paths: Vec = paths.iter().map(|p| ns_string(p)).collect(); + let ns_array = NSArray::arrayWithObjects(nil, &ns_paths); + + let mut types = vec![NSFilenamesPboardType]; + types.push(NSPasteboardTypeString); + + let types_array = NSArray::arrayWithObjects(nil, &types); + pasteboard.inner.declareTypes_owner(types_array, nil); + + pasteboard + .inner + .setPropertyList_forType(ns_array, NSFilenamesPboardType); + + let joined = paths.join("\n"); + let bytes = NSData::dataWithBytes_length_( + nil, + joined.as_ptr() as *const c_void, + joined.len() as u64, + ); + pasteboard + .inner + .setData_forType(bytes, NSPasteboardTypeString); + } + } + #[test] fn test_string() { let pasteboard = Pasteboard::unique(); @@ -339,4 +402,124 @@ mod tests { Some(ClipboardItem::new_string(text_from_other_app.to_string())) ); } + + #[test] + fn test_read_external_path() { + let pasteboard = Pasteboard::unique(); + + unsafe { + simulate_external_file_copy(&pasteboard, &["/test.txt"]); + } + + let item = pasteboard.read().expect("should read clipboard item"); + + // Test both ExternalPaths and String entries exist + assert_eq!(item.entries.len(), 2); + + // Test first entry is ExternalPaths + match &item.entries[0] { + ClipboardEntry::ExternalPaths(ep) => { + assert_eq!(ep.paths(), &[PathBuf::from("/test.txt")]); + } + other => panic!("expected ExternalPaths, got {:?}", other), + } + + // Test second entry is String + match &item.entries[1] { + ClipboardEntry::String(s) => { + assert_eq!(s.text(), "/test.txt"); + } + other => panic!("expected String, got {:?}", other), + } + } + + #[test] + fn test_read_external_paths_with_spaces() { + let pasteboard = Pasteboard::unique(); + let paths = ["/some file with spaces.txt"]; + + unsafe { + simulate_external_file_copy(&pasteboard, &paths); + } + + let item = pasteboard.read().expect("should read clipboard item"); + + match &item.entries[0] { + ClipboardEntry::ExternalPaths(ep) => { + assert_eq!(ep.paths(), &[PathBuf::from("/some file with spaces.txt")]); + } + other => panic!("expected ExternalPaths, got {:?}", other), + } + } + + #[test] + fn test_read_multiple_external_paths() { + let pasteboard = Pasteboard::unique(); + let paths = ["/file.txt", "/image.png"]; + + unsafe { + simulate_external_file_copy(&pasteboard, &paths); + } + + let item = pasteboard.read().expect("should read clipboard item"); + assert_eq!(item.entries.len(), 2); + + // Test both ExternalPaths and String entries exist + match &item.entries[0] { + ClipboardEntry::ExternalPaths(ep) => { + assert_eq!( + ep.paths(), + &[PathBuf::from("/file.txt"), PathBuf::from("/image.png"),] + ); + } + other => panic!("expected ExternalPaths, got {:?}", other), + } + + match &item.entries[1] { + ClipboardEntry::String(s) => { + assert_eq!(s.text(), "/file.txt\n/image.png"); + assert_eq!(s.metadata, None); + } + other => panic!("expected String, got {:?}", other), + } + } + + #[test] + fn test_read_image() { + let pasteboard = Pasteboard::unique(); + + // Smallest valid PNG: 1x1 transparent pixel + let png_bytes: &[u8] = &[ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, + 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, + 0x00, 0x1F, 0x15, 0xC4, 0x89, 0x00, 0x00, 0x00, 0x0A, 0x49, 0x44, 0x41, 0x54, 0x78, + 0x9C, 0x62, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, 0xE5, 0x27, 0xDE, 0xFC, 0x00, 0x00, + 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82, + ]; + + unsafe { + let ns_png_type = NSPasteboardTypePNG; + let types_array = NSArray::arrayWithObjects(nil, &[ns_png_type]); + pasteboard.inner.declareTypes_owner(types_array, nil); + + let data = NSData::dataWithBytes_length_( + nil, + png_bytes.as_ptr() as *const c_void, + png_bytes.len() as u64, + ); + pasteboard.inner.setData_forType(data, ns_png_type); + } + + let item = pasteboard.read().expect("should read PNG image"); + + // Test Image entry exists + assert_eq!(item.entries.len(), 1); + match &item.entries[0] { + ClipboardEntry::Image(img) => { + assert_eq!(img.format, ImageFormat::Png); + assert_eq!(img.bytes, png_bytes); + } + other => panic!("expected Image, got {:?}", other), + } + } } diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index 304398eca6dd05dfad8cc3e9788ad091b41baa54..652740582da8b064b8fb3036180cd386d4e2ea8f 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -19,14 +19,14 @@ use git::status::GitSummary; use git_ui; use git_ui::file_diff_view::FileDiffView; use gpui::{ - Action, AnyElement, App, AsyncWindowContext, Bounds, ClipboardItem, Context, CursorStyle, - DismissEvent, Div, DragMoveEvent, Entity, EventEmitter, ExternalPaths, FocusHandle, Focusable, - FontWeight, Hsla, InteractiveElement, KeyContext, ListHorizontalSizingBehavior, - ListSizingBehavior, Modifiers, ModifiersChangedEvent, MouseButton, MouseDownEvent, - ParentElement, PathPromptOptions, Pixels, Point, PromptLevel, Render, ScrollStrategy, Stateful, - Styled, Subscription, Task, UniformListScrollHandle, WeakEntity, Window, actions, anchored, - deferred, div, hsla, linear_color_stop, linear_gradient, point, px, size, transparent_white, - uniform_list, + Action, AnyElement, App, AsyncWindowContext, Bounds, ClipboardEntry as GpuiClipboardEntry, + ClipboardItem, Context, CursorStyle, DismissEvent, Div, DragMoveEvent, Entity, EventEmitter, + ExternalPaths, FocusHandle, Focusable, FontWeight, Hsla, InteractiveElement, KeyContext, + ListHorizontalSizingBehavior, ListSizingBehavior, Modifiers, ModifiersChangedEvent, + MouseButton, MouseDownEvent, ParentElement, PathPromptOptions, Pixels, Point, PromptLevel, + Render, ScrollStrategy, Stateful, Styled, Subscription, Task, UniformListScrollHandle, + WeakEntity, Window, actions, anchored, deferred, div, hsla, linear_color_stop, linear_gradient, + point, px, size, transparent_white, uniform_list, }; use language::DiagnosticSeverity; use menu::{Confirm, SelectFirst, SelectLast, SelectNext, SelectPrevious}; @@ -1187,6 +1187,7 @@ impl ProjectPanel { .is_some() }; + let has_pasteable_content = self.has_pasteable_content(cx); let entity = cx.entity(); let context_menu = ContextMenu::build(window, cx, |menu, _, _| { menu.context(self.focus_handle.clone()).map(|menu| { @@ -1233,11 +1234,7 @@ impl ProjectPanel { .action("Copy", Box::new(Copy)) .action("Duplicate", Box::new(Duplicate)) // TODO: Paste should always be visible, cbut disabled when clipboard is empty - .action_disabled_when( - self.clipboard.as_ref().is_none(), - "Paste", - Box::new(Paste), - ) + .action_disabled_when(!has_pasteable_content, "Paste", Box::new(Paste)) .when(is_remote, |menu| { menu.separator() .action("Download...", Box::new(DownloadFromRemote)) @@ -3000,6 +2997,7 @@ impl ProjectPanel { fn cut(&mut self, _: &Cut, _: &mut Window, cx: &mut Context) { let entries = self.disjoint_effective_entries(cx); if !entries.is_empty() { + self.write_entries_to_system_clipboard(&entries, cx); self.clipboard = Some(ClipboardEntry::Cut(entries)); cx.notify(); } @@ -3008,6 +3006,7 @@ impl ProjectPanel { fn copy(&mut self, _: &Copy, _: &mut Window, cx: &mut Context) { let entries = self.disjoint_effective_entries(cx); if !entries.is_empty() { + self.write_entries_to_system_clipboard(&entries, cx); self.clipboard = Some(ClipboardEntry::Copied(entries)); cx.notify(); } @@ -3069,6 +3068,17 @@ impl ProjectPanel { } fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context) { + if let Some(external_paths) = self.external_paths_from_system_clipboard(cx) { + let target_entry_id = self + .selection + .map(|s| s.entry_id) + .or(self.state.last_worktree_root_id); + if let Some(entry_id) = target_entry_id { + self.drop_external_files(external_paths.paths(), entry_id, window, cx); + } + return; + } + maybe!({ let (worktree, entry) = self.selected_entry_handle(cx)?; let entry = entry.clone(); @@ -3787,6 +3797,51 @@ impl ProjectPanel { Some(worktree.absolutize(&root_entry.path)) } + fn write_entries_to_system_clipboard(&self, entries: &BTreeSet, cx: &mut App) { + let project = self.project.read(cx); + let paths: Vec = entries + .iter() + .filter_map(|entry| { + let worktree = project.worktree_for_id(entry.worktree_id, cx)?; + let worktree = worktree.read(cx); + let worktree_entry = worktree.entry_for_id(entry.entry_id)?; + Some( + worktree + .abs_path() + .join(worktree_entry.path.as_std_path()) + .to_string_lossy() + .to_string(), + ) + }) + .collect(); + if !paths.is_empty() { + cx.write_to_clipboard(ClipboardItem::new_string(paths.join("\n"))); + } + } + + fn external_paths_from_system_clipboard(&self, cx: &App) -> Option { + let clipboard_item = cx.read_from_clipboard()?; + for entry in clipboard_item.entries() { + if let GpuiClipboardEntry::ExternalPaths(paths) = entry { + if !paths.paths().is_empty() { + return Some(paths.clone()); + } + } + } + None + } + + fn has_pasteable_content(&self, cx: &App) -> bool { + if self + .clipboard + .as_ref() + .is_some_and(|c| !c.items().is_empty()) + { + return true; + } + self.external_paths_from_system_clipboard(cx).is_some() + } + fn selected_entry_handle<'a>( &self, cx: &'a App, @@ -4273,19 +4328,35 @@ impl ProjectPanel { return Ok(()); } - let task = worktree.update(cx, |worktree, cx| { - worktree.copy_external_entries(target_directory, paths, fs, cx) + let (worktree_id, task) = worktree.update(cx, |worktree, cx| { + ( + worktree.id(), + worktree.copy_external_entries(target_directory, paths, fs, cx), + ) }); let opened_entries: Vec<_> = task .await .with_context(|| "failed to copy external paths")?; - this.update(cx, |this, cx| { + this.update_in(cx, |this, window, cx| { + let mut did_open = false; if open_file_after_drop && !opened_entries.is_empty() { let settings = ProjectPanelSettings::get_global(cx); if settings.auto_open.should_open_on_drop() { this.open_entry(opened_entries[0], true, false, cx); + did_open = true; + } + } + + if !did_open { + let new_selection = opened_entries + .last() + .map(|&entry_id| (worktree_id, entry_id)); + for &entry_id in &opened_entries { + this.expand_entry(worktree_id, entry_id, cx); } + this.marked_entries.clear(); + this.update_visible_entries(new_selection, false, false, window, cx); } }) } diff --git a/crates/project_panel/src/project_panel_tests.rs b/crates/project_panel/src/project_panel_tests.rs index 720ac04fdd2a656a32668add23e7af021a71ef00..1ee00c05e372df719ce5f33fd92e29269e01d0bd 100644 --- a/crates/project_panel/src/project_panel_tests.rs +++ b/crates/project_panel/src/project_panel_tests.rs @@ -1956,6 +1956,117 @@ async fn test_copy_paste_nested_and_root_entries(cx: &mut gpui::TestAppContext) ); } +#[gpui::test] +async fn test_paste_external_paths(cx: &mut gpui::TestAppContext) { + init_test(cx); + set_auto_open_settings( + cx, + ProjectPanelAutoOpenSettings { + on_drop: Some(false), + ..Default::default() + }, + ); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/root"), + json!({ + "subdir": {} + }), + ) + .await; + + fs.insert_tree( + path!("/external"), + json!({ + "new_file.rs": "fn main() {}" + }), + ) + .await; + + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let panel = workspace.update_in(cx, ProjectPanel::new); + cx.run_until_parked(); + + cx.write_to_clipboard(ClipboardItem { + entries: vec![GpuiClipboardEntry::ExternalPaths(ExternalPaths( + smallvec::smallvec![PathBuf::from(path!("/external/new_file.rs"))], + ))], + }); + + select_path(&panel, "root/subdir", cx); + panel.update_in(cx, |panel, window, cx| { + panel.paste(&Default::default(), window, cx); + }); + cx.executor().run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&panel, 0..50, cx), + &[ + "v root", + " v subdir", + " new_file.rs <== selected", + ], + ); +} + +#[gpui::test] +async fn test_copy_and_cut_write_to_system_clipboard(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/root"), + json!({ + "file_a.txt": "", + "file_b.txt": "" + }), + ) + .await; + + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let panel = workspace.update_in(cx, ProjectPanel::new); + cx.run_until_parked(); + + select_path(&panel, "root/file_a.txt", cx); + panel.update_in(cx, |panel, window, cx| { + panel.copy(&Default::default(), window, cx); + }); + + let clipboard = cx + .read_from_clipboard() + .expect("clipboard should have content after copy"); + let text = clipboard.text().expect("clipboard should contain text"); + assert!( + text.contains("file_a.txt"), + "System clipboard should contain the copied file path, got: {text}" + ); + + select_path(&panel, "root/file_b.txt", cx); + panel.update_in(cx, |panel, window, cx| { + panel.cut(&Default::default(), window, cx); + }); + + let clipboard = cx + .read_from_clipboard() + .expect("clipboard should have content after cut"); + let text = clipboard.text().expect("clipboard should contain text"); + assert!( + text.contains("file_b.txt"), + "System clipboard should contain the cut file path, got: {text}" + ); +} + #[gpui::test] async fn test_remove_opened_file(cx: &mut gpui::TestAppContext) { init_test_with_editor(cx);