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);