Detailed changes
@@ -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<ClipboardItem> {
- // 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<ClipboardEntry> {
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<id> = 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),
+ }
+ }
}
@@ -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<Self>) {
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<Self>) {
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<Self>) {
+ 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<SelectedEntry>, cx: &mut App) {
+ let project = self.project.read(cx);
+ let paths: Vec<String> = 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<ExternalPaths> {
+ 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);
}
})
}
@@ -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);