search: Respect macOS' find pasteboard (#45311)

Agus Zubiaga created

Closes #17467

Release Notes:

- On macOS, buffer search now syncs with the system find pasteboard,
allowing <kbd>⌘E</kbd> and <kbd>⌘G</kbd> to work seamlessly across Zed
and other apps.

Change summary

crates/gpui/src/app.rs                            |  36 +
crates/gpui/src/platform.rs                       |  12 
crates/gpui/src/platform/mac.rs                   |   3 
crates/gpui/src/platform/mac/attributed_string.rs | 129 -----
crates/gpui/src/platform/mac/pasteboard.rs        | 344 ++++++++++++++
crates/gpui/src/platform/mac/platform.rs          | 397 +---------------
crates/gpui/src/platform/test/platform.rs         |  24 
crates/search/src/buffer_search.rs                |  89 +++
8 files changed, 499 insertions(+), 535 deletions(-)

Detailed changes

crates/gpui/src/app.rs 🔗

@@ -1077,11 +1077,9 @@ impl App {
         self.platform.window_appearance()
     }
 
-    /// Writes data to the primary selection buffer.
-    /// Only available on Linux.
-    #[cfg(any(target_os = "linux", target_os = "freebsd"))]
-    pub fn write_to_primary(&self, item: ClipboardItem) {
-        self.platform.write_to_primary(item)
+    /// Reads data from the platform clipboard.
+    pub fn read_from_clipboard(&self) -> Option<ClipboardItem> {
+        self.platform.read_from_clipboard()
     }
 
     /// Writes data to the platform clipboard.
@@ -1096,9 +1094,31 @@ impl App {
         self.platform.read_from_primary()
     }
 
-    /// Reads data from the platform clipboard.
-    pub fn read_from_clipboard(&self) -> Option<ClipboardItem> {
-        self.platform.read_from_clipboard()
+    /// Writes data to the primary selection buffer.
+    /// Only available on Linux.
+    #[cfg(any(target_os = "linux", target_os = "freebsd"))]
+    pub fn write_to_primary(&self, item: ClipboardItem) {
+        self.platform.write_to_primary(item)
+    }
+
+    /// Reads data from macOS's "Find" pasteboard.
+    ///
+    /// Used to share the current search string between apps.
+    ///
+    /// https://developer.apple.com/documentation/appkit/nspasteboard/name-swift.struct/find
+    #[cfg(target_os = "macos")]
+    pub fn read_from_find_pasteboard(&self) -> Option<ClipboardItem> {
+        self.platform.read_from_find_pasteboard()
+    }
+
+    /// Writes data to macOS's "Find" pasteboard.
+    ///
+    /// Used to share the current search string between apps.
+    ///
+    /// https://developer.apple.com/documentation/appkit/nspasteboard/name-swift.struct/find
+    #[cfg(target_os = "macos")]
+    pub fn write_to_find_pasteboard(&self, item: ClipboardItem) {
+        self.platform.write_to_find_pasteboard(item)
     }
 
     /// Writes credentials to the platform keychain.

crates/gpui/src/platform.rs 🔗

@@ -262,12 +262,18 @@ pub(crate) trait Platform: 'static {
     fn set_cursor_style(&self, style: CursorStyle);
     fn should_auto_hide_scrollbars(&self) -> bool;
 
-    #[cfg(any(target_os = "linux", target_os = "freebsd"))]
-    fn write_to_primary(&self, item: ClipboardItem);
+    fn read_from_clipboard(&self) -> Option<ClipboardItem>;
     fn write_to_clipboard(&self, item: ClipboardItem);
+
     #[cfg(any(target_os = "linux", target_os = "freebsd"))]
     fn read_from_primary(&self) -> Option<ClipboardItem>;
-    fn read_from_clipboard(&self) -> Option<ClipboardItem>;
+    #[cfg(any(target_os = "linux", target_os = "freebsd"))]
+    fn write_to_primary(&self, item: ClipboardItem);
+
+    #[cfg(target_os = "macos")]
+    fn read_from_find_pasteboard(&self) -> Option<ClipboardItem>;
+    #[cfg(target_os = "macos")]
+    fn write_to_find_pasteboard(&self, item: ClipboardItem);
 
     fn write_credentials(&self, url: &str, username: &str, password: &[u8]) -> Task<Result<()>>;
     fn read_credentials(&self, url: &str) -> Task<Result<Option<(String, Vec<u8>)>>>;

crates/gpui/src/platform/mac.rs 🔗

@@ -5,6 +5,7 @@ mod display;
 mod display_link;
 mod events;
 mod keyboard;
+mod pasteboard;
 
 #[cfg(feature = "screen-capture")]
 mod screen_capture;
@@ -21,8 +22,6 @@ use metal_renderer as renderer;
 #[cfg(feature = "macos-blade")]
 use crate::platform::blade as renderer;
 
-mod attributed_string;
-
 #[cfg(feature = "font-kit")]
 mod open_type;
 

crates/gpui/src/platform/mac/attributed_string.rs 🔗

@@ -1,129 +0,0 @@
-use cocoa::base::id;
-use cocoa::foundation::NSRange;
-use objc::{class, msg_send, sel, sel_impl};
-
-/// The `cocoa` crate does not define NSAttributedString (and related Cocoa classes),
-/// which are needed for copying rich text (that is, text intermingled with images)
-/// to the clipboard. This adds access to those APIs.
-#[allow(non_snake_case)]
-pub trait NSAttributedString: Sized {
-    unsafe fn alloc(_: Self) -> id {
-        msg_send![class!(NSAttributedString), alloc]
-    }
-
-    unsafe fn init_attributed_string(self, string: id) -> id;
-    unsafe fn appendAttributedString_(self, attr_string: id);
-    unsafe fn RTFDFromRange_documentAttributes_(self, range: NSRange, attrs: id) -> id;
-    unsafe fn RTFFromRange_documentAttributes_(self, range: NSRange, attrs: id) -> id;
-    unsafe fn string(self) -> id;
-}
-
-impl NSAttributedString for id {
-    unsafe fn init_attributed_string(self, string: id) -> id {
-        msg_send![self, initWithString: string]
-    }
-
-    unsafe fn appendAttributedString_(self, attr_string: id) {
-        let _: () = msg_send![self, appendAttributedString: attr_string];
-    }
-
-    unsafe fn RTFDFromRange_documentAttributes_(self, range: NSRange, attrs: id) -> id {
-        msg_send![self, RTFDFromRange: range documentAttributes: attrs]
-    }
-
-    unsafe fn RTFFromRange_documentAttributes_(self, range: NSRange, attrs: id) -> id {
-        msg_send![self, RTFFromRange: range documentAttributes: attrs]
-    }
-
-    unsafe fn string(self) -> id {
-        msg_send![self, string]
-    }
-}
-
-pub trait NSMutableAttributedString: NSAttributedString {
-    unsafe fn alloc(_: Self) -> id {
-        msg_send![class!(NSMutableAttributedString), alloc]
-    }
-}
-
-impl NSMutableAttributedString for id {}
-
-#[cfg(test)]
-mod tests {
-    use crate::platform::mac::ns_string;
-
-    use super::*;
-    use cocoa::appkit::NSImage;
-    use cocoa::base::nil;
-    use cocoa::foundation::NSAutoreleasePool;
-    #[test]
-    #[ignore] // This was SIGSEGV-ing on CI but not locally; need to investigate https://github.com/zed-industries/zed/actions/runs/10362363230/job/28684225486?pr=15782#step:4:1348
-    fn test_nsattributed_string() {
-        // TODO move these to parent module once it's actually ready to be used
-        #[allow(non_snake_case)]
-        pub trait NSTextAttachment: Sized {
-            unsafe fn alloc(_: Self) -> id {
-                msg_send![class!(NSTextAttachment), alloc]
-            }
-        }
-
-        impl NSTextAttachment for id {}
-
-        unsafe {
-            let image: id = {
-                let img: id = msg_send![class!(NSImage), alloc];
-                let img: id = msg_send![img, initWithContentsOfFile: ns_string("test.jpeg")];
-                let img: id = msg_send![img, autorelease];
-                img
-            };
-            let _size = image.size();
-
-            let string = ns_string("Test String");
-            let attr_string = NSMutableAttributedString::alloc(nil)
-                .init_attributed_string(string)
-                .autorelease();
-            let hello_string = ns_string("Hello World");
-            let hello_attr_string = NSAttributedString::alloc(nil)
-                .init_attributed_string(hello_string)
-                .autorelease();
-            attr_string.appendAttributedString_(hello_attr_string);
-
-            let attachment: id = msg_send![NSTextAttachment::alloc(nil), autorelease];
-            let _: () = msg_send![attachment, setImage: image];
-            let image_attr_string =
-                msg_send![class!(NSAttributedString), attributedStringWithAttachment: attachment];
-            attr_string.appendAttributedString_(image_attr_string);
-
-            let another_string = ns_string("Another String");
-            let another_attr_string = NSAttributedString::alloc(nil)
-                .init_attributed_string(another_string)
-                .autorelease();
-            attr_string.appendAttributedString_(another_attr_string);
-
-            let _len: cocoa::foundation::NSUInteger = msg_send![attr_string, length];
-
-            ///////////////////////////////////////////////////
-            // pasteboard.clearContents();
-
-            let rtfd_data = attr_string.RTFDFromRange_documentAttributes_(
-                NSRange::new(0, msg_send![attr_string, length]),
-                nil,
-            );
-            assert_ne!(rtfd_data, nil);
-            // if rtfd_data != nil {
-            //     pasteboard.setData_forType(rtfd_data, NSPasteboardTypeRTFD);
-            // }
-
-            // let rtf_data = attributed_string.RTFFromRange_documentAttributes_(
-            //     NSRange::new(0, attributed_string.length()),
-            //     nil,
-            // );
-            // if rtf_data != nil {
-            //     pasteboard.setData_forType(rtf_data, NSPasteboardTypeRTF);
-            // }
-
-            // let plain_text = attributed_string.string();
-            // pasteboard.setString_forType(plain_text, NSPasteboardTypeString);
-        }
-    }
-}

crates/gpui/src/platform/mac/pasteboard.rs 🔗

@@ -0,0 +1,344 @@
+use core::slice;
+use std::ffi::c_void;
+
+use cocoa::{
+    appkit::{NSPasteboard, NSPasteboardTypePNG, NSPasteboardTypeString, NSPasteboardTypeTIFF},
+    base::{id, nil},
+    foundation::NSData,
+};
+use objc::{msg_send, runtime::Object, sel, sel_impl};
+use strum::IntoEnumIterator as _;
+
+use crate::{
+    ClipboardEntry, ClipboardItem, ClipboardString, Image, ImageFormat, asset_cache::hash,
+    platform::mac::ns_string,
+};
+
+pub struct Pasteboard {
+    inner: id,
+    text_hash_type: id,
+    metadata_type: id,
+}
+
+impl Pasteboard {
+    pub fn general() -> Self {
+        unsafe { Self::new(NSPasteboard::generalPasteboard(nil)) }
+    }
+
+    pub fn find() -> Self {
+        unsafe { Self::new(NSPasteboard::pasteboardWithName(nil, NSPasteboardNameFind)) }
+    }
+
+    #[cfg(test)]
+    pub fn unique() -> Self {
+        unsafe { Self::new(NSPasteboard::pasteboardWithUniqueName(nil)) }
+    }
+
+    unsafe fn new(inner: id) -> Self {
+        Self {
+            inner,
+            text_hash_type: unsafe { ns_string("zed-text-hash") },
+            metadata_type: unsafe { ns_string("zed-metadata") },
+        }
+    }
+
+    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");
+
+            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));
+                }
+            }
+
+            // If it wasn't a string, try the various supported image types.
+            for format in ImageFormat::iter() {
+                if let Some(item) = self.read_image(format) {
+                    return Some(item);
+                }
+            }
+        }
+
+        // If it wasn't a string or a supported image type, give up.
+        None
+    }
+
+    fn read_image(&self, format: ImageFormat) -> Option<ClipboardItem> {
+        let mut ut_type: UTType = format.into();
+
+        unsafe {
+            let types: id = self.inner.types();
+            if msg_send![types, containsObject: ut_type.inner()] {
+                self.data_for_type(ut_type.inner_mut()).map(|bytes| {
+                    let bytes = bytes.to_vec();
+                    let id = hash(&bytes);
+
+                    ClipboardItem {
+                        entries: vec![ClipboardEntry::Image(Image { format, bytes, id })],
+                    }
+                })
+            } else {
+                None
+            }
+        }
+    }
+
+    fn read_string(&self, text_bytes: &[u8]) -> ClipboardItem {
+        unsafe {
+            let text = String::from_utf8_lossy(text_bytes).to_string();
+            let metadata = self
+                .data_for_type(self.text_hash_type)
+                .and_then(|hash_bytes| {
+                    let hash_bytes = hash_bytes.try_into().ok()?;
+                    let hash = u64::from_be_bytes(hash_bytes);
+                    let metadata = self.data_for_type(self.metadata_type)?;
+
+                    if hash == ClipboardString::text_hash(&text) {
+                        String::from_utf8(metadata.to_vec()).ok()
+                    } else {
+                        None
+                    }
+                });
+
+            ClipboardItem {
+                entries: vec![ClipboardEntry::String(ClipboardString { text, metadata })],
+            }
+        }
+    }
+
+    unsafe fn data_for_type(&self, kind: id) -> Option<&[u8]> {
+        unsafe {
+            let data = self.inner.dataForType(kind);
+            if data == nil {
+                None
+            } else {
+                Some(slice::from_raw_parts(
+                    data.bytes() as *mut u8,
+                    data.length() as usize,
+                ))
+            }
+        }
+    }
+
+    pub fn write(&self, item: ClipboardItem) {
+        unsafe {
+            match item.entries.as_slice() {
+                [] => {
+                    // Writing an empty list of entries just clears the clipboard.
+                    self.inner.clearContents();
+                }
+                [ClipboardEntry::String(string)] => {
+                    self.write_plaintext(string);
+                }
+                [ClipboardEntry::Image(image)] => {
+                    self.write_image(image);
+                }
+                [ClipboardEntry::ExternalPaths(_)] => {}
+                _ => {
+                    // Agus NB: We're currently only writing string entries to the clipboard when we have more than one.
+                    //
+                    // This was the existing behavior before I refactored the outer clipboard code:
+                    // https://github.com/zed-industries/zed/blob/65f7412a0265552b06ce122655369d6cc7381dd6/crates/gpui/src/platform/mac/platform.rs#L1060-L1110
+                    //
+                    // Note how `any_images` is always `false`. We should fix that, but that's orthogonal to the refactor.
+
+                    let mut combined = ClipboardString {
+                        text: String::new(),
+                        metadata: None,
+                    };
+
+                    for entry in item.entries {
+                        match entry {
+                            ClipboardEntry::String(text) => {
+                                combined.text.push_str(&text.text());
+                                if combined.metadata.is_none() {
+                                    combined.metadata = text.metadata;
+                                }
+                            }
+                            _ => {}
+                        }
+                    }
+
+                    self.write_plaintext(&combined);
+                }
+            }
+        }
+    }
+
+    fn write_plaintext(&self, string: &ClipboardString) {
+        unsafe {
+            self.inner.clearContents();
+
+            let text_bytes = NSData::dataWithBytes_length_(
+                nil,
+                string.text.as_ptr() as *const c_void,
+                string.text.len() as u64,
+            );
+            self.inner
+                .setData_forType(text_bytes, NSPasteboardTypeString);
+
+            if let Some(metadata) = string.metadata.as_ref() {
+                let hash_bytes = ClipboardString::text_hash(&string.text).to_be_bytes();
+                let hash_bytes = NSData::dataWithBytes_length_(
+                    nil,
+                    hash_bytes.as_ptr() as *const c_void,
+                    hash_bytes.len() as u64,
+                );
+                self.inner.setData_forType(hash_bytes, self.text_hash_type);
+
+                let metadata_bytes = NSData::dataWithBytes_length_(
+                    nil,
+                    metadata.as_ptr() as *const c_void,
+                    metadata.len() as u64,
+                );
+                self.inner
+                    .setData_forType(metadata_bytes, self.metadata_type);
+            }
+        }
+    }
+
+    unsafe fn write_image(&self, image: &Image) {
+        unsafe {
+            self.inner.clearContents();
+
+            let bytes = NSData::dataWithBytes_length_(
+                nil,
+                image.bytes.as_ptr() as *const c_void,
+                image.bytes.len() as u64,
+            );
+
+            self.inner
+                .setData_forType(bytes, Into::<UTType>::into(image.format).inner_mut());
+        }
+    }
+}
+
+#[link(name = "AppKit", kind = "framework")]
+unsafe extern "C" {
+    /// [Apple's documentation](https://developer.apple.com/documentation/appkit/nspasteboardnamefind?language=objc)
+    pub static NSPasteboardNameFind: id;
+}
+
+impl From<ImageFormat> for UTType {
+    fn from(value: ImageFormat) -> Self {
+        match value {
+            ImageFormat::Png => Self::png(),
+            ImageFormat::Jpeg => Self::jpeg(),
+            ImageFormat::Tiff => Self::tiff(),
+            ImageFormat::Webp => Self::webp(),
+            ImageFormat::Gif => Self::gif(),
+            ImageFormat::Bmp => Self::bmp(),
+            ImageFormat::Svg => Self::svg(),
+            ImageFormat::Ico => Self::ico(),
+        }
+    }
+}
+
+// See https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/
+pub struct UTType(id);
+
+impl UTType {
+    pub fn png() -> Self {
+        // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/png
+        Self(unsafe { NSPasteboardTypePNG }) // This is a rare case where there's a built-in NSPasteboardType
+    }
+
+    pub fn jpeg() -> Self {
+        // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/jpeg
+        Self(unsafe { ns_string("public.jpeg") })
+    }
+
+    pub fn gif() -> Self {
+        // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/gif
+        Self(unsafe { ns_string("com.compuserve.gif") })
+    }
+
+    pub fn webp() -> Self {
+        // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/webp
+        Self(unsafe { ns_string("org.webmproject.webp") })
+    }
+
+    pub fn bmp() -> Self {
+        // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/bmp
+        Self(unsafe { ns_string("com.microsoft.bmp") })
+    }
+
+    pub fn svg() -> Self {
+        // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/svg
+        Self(unsafe { ns_string("public.svg-image") })
+    }
+
+    pub fn ico() -> Self {
+        // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/ico
+        Self(unsafe { ns_string("com.microsoft.ico") })
+    }
+
+    pub fn tiff() -> Self {
+        // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/tiff
+        Self(unsafe { NSPasteboardTypeTIFF }) // This is a rare case where there's a built-in NSPasteboardType
+    }
+
+    fn inner(&self) -> *const Object {
+        self.0
+    }
+
+    pub fn inner_mut(&self) -> *mut Object {
+        self.0 as *mut _
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use cocoa::{appkit::NSPasteboardTypeString, foundation::NSData};
+
+    use crate::{ClipboardEntry, ClipboardItem, ClipboardString};
+
+    use super::*;
+
+    #[test]
+    fn test_string() {
+        let pasteboard = Pasteboard::unique();
+        assert_eq!(pasteboard.read(), None);
+
+        let item = ClipboardItem::new_string("1".to_string());
+        pasteboard.write(item.clone());
+        assert_eq!(pasteboard.read(), Some(item));
+
+        let item = ClipboardItem {
+            entries: vec![ClipboardEntry::String(
+                ClipboardString::new("2".to_string()).with_json_metadata(vec![3, 4]),
+            )],
+        };
+        pasteboard.write(item.clone());
+        assert_eq!(pasteboard.read(), Some(item));
+
+        let text_from_other_app = "text from other app";
+        unsafe {
+            let bytes = NSData::dataWithBytes_length_(
+                nil,
+                text_from_other_app.as_ptr() as *const c_void,
+                text_from_other_app.len() as u64,
+            );
+            pasteboard
+                .inner
+                .setData_forType(bytes, NSPasteboardTypeString);
+        }
+        assert_eq!(
+            pasteboard.read(),
+            Some(ClipboardItem::new_string(text_from_other_app.to_string()))
+        );
+    }
+}

crates/gpui/src/platform/mac/platform.rs 🔗

@@ -1,29 +1,24 @@
 use super::{
-    BoolExt, MacKeyboardLayout, MacKeyboardMapper,
-    attributed_string::{NSAttributedString, NSMutableAttributedString},
-    events::key_to_native,
-    ns_string, renderer,
+    BoolExt, MacKeyboardLayout, MacKeyboardMapper, events::key_to_native, ns_string, renderer,
 };
 use crate::{
-    Action, AnyWindowHandle, BackgroundExecutor, ClipboardEntry, ClipboardItem, ClipboardString,
-    CursorStyle, ForegroundExecutor, Image, ImageFormat, KeyContext, Keymap, MacDispatcher,
-    MacDisplay, MacWindow, Menu, MenuItem, OsMenu, OwnedMenu, PathPromptOptions, Platform,
-    PlatformDisplay, PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem,
-    PlatformWindow, Result, SystemMenuType, Task, WindowAppearance, WindowParams, hash,
+    Action, AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, ForegroundExecutor,
+    KeyContext, Keymap, MacDispatcher, MacDisplay, MacWindow, Menu, MenuItem, OsMenu, OwnedMenu,
+    PathPromptOptions, Platform, PlatformDisplay, PlatformKeyboardLayout, PlatformKeyboardMapper,
+    PlatformTextSystem, PlatformWindow, Result, SystemMenuType, Task, WindowAppearance,
+    WindowParams, platform::mac::pasteboard::Pasteboard,
 };
 use anyhow::{Context as _, anyhow};
 use block::ConcreteBlock;
 use cocoa::{
     appkit::{
         NSApplication, NSApplicationActivationPolicy::NSApplicationActivationPolicyRegular,
-        NSEventModifierFlags, NSMenu, NSMenuItem, NSModalResponse, NSOpenPanel, NSPasteboard,
-        NSPasteboardTypePNG, NSPasteboardTypeRTF, NSPasteboardTypeRTFD, NSPasteboardTypeString,
-        NSPasteboardTypeTIFF, NSSavePanel, NSVisualEffectState, NSVisualEffectView, NSWindow,
+        NSEventModifierFlags, NSMenu, NSMenuItem, NSModalResponse, NSOpenPanel, NSSavePanel,
+        NSVisualEffectState, NSVisualEffectView, NSWindow,
     },
     base::{BOOL, NO, YES, id, nil, selector},
     foundation::{
-        NSArray, NSAutoreleasePool, NSBundle, NSData, NSInteger, NSProcessInfo, NSRange, NSString,
-        NSUInteger, NSURL,
+        NSArray, NSAutoreleasePool, NSBundle, NSInteger, NSProcessInfo, NSString, NSUInteger, NSURL,
     },
 };
 use core_foundation::{
@@ -49,7 +44,6 @@ use ptr::null_mut;
 use semver::Version;
 use std::{
     cell::Cell,
-    convert::TryInto,
     ffi::{CStr, OsStr, c_void},
     os::{raw::c_char, unix::ffi::OsStrExt},
     path::{Path, PathBuf},
@@ -58,7 +52,6 @@ use std::{
     slice, str,
     sync::{Arc, OnceLock},
 };
-use strum::IntoEnumIterator;
 use util::{
     ResultExt,
     command::{new_smol_command, new_std_command},
@@ -164,9 +157,8 @@ pub(crate) struct MacPlatformState {
     text_system: Arc<dyn PlatformTextSystem>,
     renderer_context: renderer::Context,
     headless: bool,
-    pasteboard: id,
-    text_hash_pasteboard_type: id,
-    metadata_pasteboard_type: id,
+    general_pasteboard: Pasteboard,
+    find_pasteboard: Pasteboard,
     reopen: Option<Box<dyn FnMut()>>,
     on_keyboard_layout_change: Option<Box<dyn FnMut()>>,
     quit: Option<Box<dyn FnMut()>>,
@@ -206,9 +198,8 @@ impl MacPlatform {
             background_executor: BackgroundExecutor::new(dispatcher.clone()),
             foreground_executor: ForegroundExecutor::new(dispatcher),
             renderer_context: renderer::Context::default(),
-            pasteboard: unsafe { NSPasteboard::generalPasteboard(nil) },
-            text_hash_pasteboard_type: unsafe { ns_string("zed-text-hash") },
-            metadata_pasteboard_type: unsafe { ns_string("zed-metadata") },
+            general_pasteboard: Pasteboard::general(),
+            find_pasteboard: Pasteboard::find(),
             reopen: None,
             quit: None,
             menu_command: None,
@@ -224,20 +215,6 @@ impl MacPlatform {
         }))
     }
 
-    unsafe fn read_from_pasteboard(&self, pasteboard: *mut Object, kind: id) -> Option<&[u8]> {
-        unsafe {
-            let data = pasteboard.dataForType(kind);
-            if data == nil {
-                None
-            } else {
-                Some(slice::from_raw_parts(
-                    data.bytes() as *mut u8,
-                    data.length() as usize,
-                ))
-            }
-        }
-    }
-
     unsafe fn create_menu_bar(
         &self,
         menus: &Vec<Menu>,
@@ -1034,119 +1011,24 @@ impl Platform for MacPlatform {
         }
     }
 
-    fn write_to_clipboard(&self, item: ClipboardItem) {
-        use crate::ClipboardEntry;
-
-        unsafe {
-            // We only want to use NSAttributedString if there are multiple entries to write.
-            if item.entries.len() <= 1 {
-                match item.entries.first() {
-                    Some(entry) => match entry {
-                        ClipboardEntry::String(string) => {
-                            self.write_plaintext_to_clipboard(string);
-                        }
-                        ClipboardEntry::Image(image) => {
-                            self.write_image_to_clipboard(image);
-                        }
-                        ClipboardEntry::ExternalPaths(_) => {}
-                    },
-                    None => {
-                        // Writing an empty list of entries just clears the clipboard.
-                        let state = self.0.lock();
-                        state.pasteboard.clearContents();
-                    }
-                }
-            } else {
-                let mut any_images = false;
-                let attributed_string = {
-                    let mut buf = NSMutableAttributedString::alloc(nil)
-                        // TODO can we skip this? Or at least part of it?
-                        .init_attributed_string(ns_string(""))
-                        .autorelease();
-
-                    for entry in item.entries {
-                        if let ClipboardEntry::String(ClipboardString { text, metadata: _ }) = entry
-                        {
-                            let to_append = NSAttributedString::alloc(nil)
-                                .init_attributed_string(ns_string(&text))
-                                .autorelease();
-
-                            buf.appendAttributedString_(to_append);
-                        }
-                    }
-
-                    buf
-                };
-
-                let state = self.0.lock();
-                state.pasteboard.clearContents();
-
-                // Only set rich text clipboard types if we actually have 1+ images to include.
-                if any_images {
-                    let rtfd_data = attributed_string.RTFDFromRange_documentAttributes_(
-                        NSRange::new(0, msg_send![attributed_string, length]),
-                        nil,
-                    );
-                    if rtfd_data != nil {
-                        state
-                            .pasteboard
-                            .setData_forType(rtfd_data, NSPasteboardTypeRTFD);
-                    }
-
-                    let rtf_data = attributed_string.RTFFromRange_documentAttributes_(
-                        NSRange::new(0, attributed_string.length()),
-                        nil,
-                    );
-                    if rtf_data != nil {
-                        state
-                            .pasteboard
-                            .setData_forType(rtf_data, NSPasteboardTypeRTF);
-                    }
-                }
-
-                let plain_text = attributed_string.string();
-                state
-                    .pasteboard
-                    .setString_forType(plain_text, NSPasteboardTypeString);
-            }
-        }
-    }
-
     fn read_from_clipboard(&self) -> Option<ClipboardItem> {
         let state = self.0.lock();
-        let pasteboard = state.pasteboard;
-
-        // First, see if it's a string.
-        unsafe {
-            let types: id = pasteboard.types();
-            let string_type: id = ns_string("public.utf8-plain-text");
-
-            if msg_send![types, containsObject: string_type] {
-                let data = pasteboard.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_from_clipboard(&state, &[]));
-                } else {
-                    let bytes =
-                        slice::from_raw_parts(data.bytes() as *mut u8, data.length() as usize);
+        state.general_pasteboard.read()
+    }
 
-                    return Some(self.read_string_from_clipboard(&state, bytes));
-                }
-            }
+    fn write_to_clipboard(&self, item: ClipboardItem) {
+        let state = self.0.lock();
+        state.general_pasteboard.write(item);
+    }
 
-            // If it wasn't a string, try the various supported image types.
-            for format in ImageFormat::iter() {
-                if let Some(item) = try_clipboard_image(pasteboard, format) {
-                    return Some(item);
-                }
-            }
-        }
+    fn read_from_find_pasteboard(&self) -> Option<ClipboardItem> {
+        let state = self.0.lock();
+        state.find_pasteboard.read()
+    }
 
-        // If it wasn't a string or a supported image type, give up.
-        None
+    fn write_to_find_pasteboard(&self, item: ClipboardItem) {
+        let state = self.0.lock();
+        state.find_pasteboard.write(item);
     }
 
     fn write_credentials(&self, url: &str, username: &str, password: &[u8]) -> Task<Result<()>> {
@@ -1255,116 +1137,6 @@ impl Platform for MacPlatform {
     }
 }
 
-impl MacPlatform {
-    unsafe fn read_string_from_clipboard(
-        &self,
-        state: &MacPlatformState,
-        text_bytes: &[u8],
-    ) -> ClipboardItem {
-        unsafe {
-            let text = String::from_utf8_lossy(text_bytes).to_string();
-            let metadata = self
-                .read_from_pasteboard(state.pasteboard, state.text_hash_pasteboard_type)
-                .and_then(|hash_bytes| {
-                    let hash_bytes = hash_bytes.try_into().ok()?;
-                    let hash = u64::from_be_bytes(hash_bytes);
-                    let metadata = self
-                        .read_from_pasteboard(state.pasteboard, state.metadata_pasteboard_type)?;
-
-                    if hash == ClipboardString::text_hash(&text) {
-                        String::from_utf8(metadata.to_vec()).ok()
-                    } else {
-                        None
-                    }
-                });
-
-            ClipboardItem {
-                entries: vec![ClipboardEntry::String(ClipboardString { text, metadata })],
-            }
-        }
-    }
-
-    unsafe fn write_plaintext_to_clipboard(&self, string: &ClipboardString) {
-        unsafe {
-            let state = self.0.lock();
-            state.pasteboard.clearContents();
-
-            let text_bytes = NSData::dataWithBytes_length_(
-                nil,
-                string.text.as_ptr() as *const c_void,
-                string.text.len() as u64,
-            );
-            state
-                .pasteboard
-                .setData_forType(text_bytes, NSPasteboardTypeString);
-
-            if let Some(metadata) = string.metadata.as_ref() {
-                let hash_bytes = ClipboardString::text_hash(&string.text).to_be_bytes();
-                let hash_bytes = NSData::dataWithBytes_length_(
-                    nil,
-                    hash_bytes.as_ptr() as *const c_void,
-                    hash_bytes.len() as u64,
-                );
-                state
-                    .pasteboard
-                    .setData_forType(hash_bytes, state.text_hash_pasteboard_type);
-
-                let metadata_bytes = NSData::dataWithBytes_length_(
-                    nil,
-                    metadata.as_ptr() as *const c_void,
-                    metadata.len() as u64,
-                );
-                state
-                    .pasteboard
-                    .setData_forType(metadata_bytes, state.metadata_pasteboard_type);
-            }
-        }
-    }
-
-    unsafe fn write_image_to_clipboard(&self, image: &Image) {
-        unsafe {
-            let state = self.0.lock();
-            state.pasteboard.clearContents();
-
-            let bytes = NSData::dataWithBytes_length_(
-                nil,
-                image.bytes.as_ptr() as *const c_void,
-                image.bytes.len() as u64,
-            );
-
-            state
-                .pasteboard
-                .setData_forType(bytes, Into::<UTType>::into(image.format).inner_mut());
-        }
-    }
-}
-
-fn try_clipboard_image(pasteboard: id, format: ImageFormat) -> Option<ClipboardItem> {
-    let mut ut_type: UTType = format.into();
-
-    unsafe {
-        let types: id = pasteboard.types();
-        if msg_send![types, containsObject: ut_type.inner()] {
-            let data = pasteboard.dataForType(ut_type.inner_mut());
-            if data == nil {
-                None
-            } else {
-                let bytes = Vec::from(slice::from_raw_parts(
-                    data.bytes() as *mut u8,
-                    data.length() as usize,
-                ));
-                let id = hash(&bytes);
-
-                Some(ClipboardItem {
-                    entries: vec![ClipboardEntry::Image(Image { format, bytes, id })],
-                })
-            }
-        } else {
-            None
-        }
-    }
-}
-
 unsafe fn path_from_objc(path: id) -> PathBuf {
     let len = msg_send![path, lengthOfBytesUsingEncoding: NSUTF8StringEncoding];
     let bytes = unsafe { path.UTF8String() as *const u8 };
@@ -1605,120 +1377,3 @@ mod security {
     pub const errSecUserCanceled: OSStatus = -128;
     pub const errSecItemNotFound: OSStatus = -25300;
 }
-
-impl From<ImageFormat> for UTType {
-    fn from(value: ImageFormat) -> Self {
-        match value {
-            ImageFormat::Png => Self::png(),
-            ImageFormat::Jpeg => Self::jpeg(),
-            ImageFormat::Tiff => Self::tiff(),
-            ImageFormat::Webp => Self::webp(),
-            ImageFormat::Gif => Self::gif(),
-            ImageFormat::Bmp => Self::bmp(),
-            ImageFormat::Svg => Self::svg(),
-            ImageFormat::Ico => Self::ico(),
-        }
-    }
-}
-
-// See https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/
-struct UTType(id);
-
-impl UTType {
-    pub fn png() -> Self {
-        // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/png
-        Self(unsafe { NSPasteboardTypePNG }) // This is a rare case where there's a built-in NSPasteboardType
-    }
-
-    pub fn jpeg() -> Self {
-        // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/jpeg
-        Self(unsafe { ns_string("public.jpeg") })
-    }
-
-    pub fn gif() -> Self {
-        // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/gif
-        Self(unsafe { ns_string("com.compuserve.gif") })
-    }
-
-    pub fn webp() -> Self {
-        // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/webp
-        Self(unsafe { ns_string("org.webmproject.webp") })
-    }
-
-    pub fn bmp() -> Self {
-        // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/bmp
-        Self(unsafe { ns_string("com.microsoft.bmp") })
-    }
-
-    pub fn svg() -> Self {
-        // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/svg
-        Self(unsafe { ns_string("public.svg-image") })
-    }
-
-    pub fn ico() -> Self {
-        // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/ico
-        Self(unsafe { ns_string("com.microsoft.ico") })
-    }
-
-    pub fn tiff() -> Self {
-        // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/tiff
-        Self(unsafe { NSPasteboardTypeTIFF }) // This is a rare case where there's a built-in NSPasteboardType
-    }
-
-    fn inner(&self) -> *const Object {
-        self.0
-    }
-
-    fn inner_mut(&self) -> *mut Object {
-        self.0 as *mut _
-    }
-}
-
-#[cfg(test)]
-mod tests {
-    use crate::ClipboardItem;
-
-    use super::*;
-
-    #[test]
-    fn test_clipboard() {
-        let platform = build_platform();
-        assert_eq!(platform.read_from_clipboard(), None);
-
-        let item = ClipboardItem::new_string("1".to_string());
-        platform.write_to_clipboard(item.clone());
-        assert_eq!(platform.read_from_clipboard(), Some(item));
-
-        let item = ClipboardItem {
-            entries: vec![ClipboardEntry::String(
-                ClipboardString::new("2".to_string()).with_json_metadata(vec![3, 4]),
-            )],
-        };
-        platform.write_to_clipboard(item.clone());
-        assert_eq!(platform.read_from_clipboard(), Some(item));
-
-        let text_from_other_app = "text from other app";
-        unsafe {
-            let bytes = NSData::dataWithBytes_length_(
-                nil,
-                text_from_other_app.as_ptr() as *const c_void,
-                text_from_other_app.len() as u64,
-            );
-            platform
-                .0
-                .lock()
-                .pasteboard
-                .setData_forType(bytes, NSPasteboardTypeString);
-        }
-        assert_eq!(
-            platform.read_from_clipboard(),
-            Some(ClipboardItem::new_string(text_from_other_app.to_string()))
-        );
-    }
-
-    fn build_platform() -> MacPlatform {
-        let platform = MacPlatform::new(false);
-        platform.0.lock().pasteboard = unsafe { NSPasteboard::pasteboardWithUniqueName(nil) };
-        platform
-    }
-}

crates/gpui/src/platform/test/platform.rs 🔗

@@ -32,6 +32,8 @@ pub(crate) struct TestPlatform {
     current_clipboard_item: Mutex<Option<ClipboardItem>>,
     #[cfg(any(target_os = "linux", target_os = "freebsd"))]
     current_primary_item: Mutex<Option<ClipboardItem>>,
+    #[cfg(target_os = "macos")]
+    current_find_pasteboard_item: Mutex<Option<ClipboardItem>>,
     pub(crate) prompts: RefCell<TestPrompts>,
     screen_capture_sources: RefCell<Vec<TestScreenCaptureSource>>,
     pub opened_url: RefCell<Option<String>>,
@@ -117,6 +119,8 @@ impl TestPlatform {
             current_clipboard_item: Mutex::new(None),
             #[cfg(any(target_os = "linux", target_os = "freebsd"))]
             current_primary_item: Mutex::new(None),
+            #[cfg(target_os = "macos")]
+            current_find_pasteboard_item: Mutex::new(None),
             weak: weak.clone(),
             opened_url: Default::default(),
             #[cfg(target_os = "windows")]
@@ -398,9 +402,8 @@ impl Platform for TestPlatform {
         false
     }
 
-    #[cfg(any(target_os = "linux", target_os = "freebsd"))]
-    fn write_to_primary(&self, item: ClipboardItem) {
-        *self.current_primary_item.lock() = Some(item);
+    fn read_from_clipboard(&self) -> Option<ClipboardItem> {
+        self.current_clipboard_item.lock().clone()
     }
 
     fn write_to_clipboard(&self, item: ClipboardItem) {
@@ -412,8 +415,19 @@ impl Platform for TestPlatform {
         self.current_primary_item.lock().clone()
     }
 
-    fn read_from_clipboard(&self) -> Option<ClipboardItem> {
-        self.current_clipboard_item.lock().clone()
+    #[cfg(any(target_os = "linux", target_os = "freebsd"))]
+    fn write_to_primary(&self, item: ClipboardItem) {
+        *self.current_primary_item.lock() = Some(item);
+    }
+
+    #[cfg(target_os = "macos")]
+    fn read_from_find_pasteboard(&self) -> Option<ClipboardItem> {
+        self.current_find_pasteboard_item.lock().clone()
+    }
+
+    #[cfg(target_os = "macos")]
+    fn write_to_find_pasteboard(&self, item: ClipboardItem) {
+        *self.current_find_pasteboard_item.lock() = Some(item);
     }
 
     fn write_credentials(&self, _url: &str, _username: &str, _password: &[u8]) -> Task<Result<()>> {

crates/search/src/buffer_search.rs 🔗

@@ -106,7 +106,10 @@ pub struct BufferSearchBar {
     replacement_editor_focused: bool,
     active_searchable_item: Option<Box<dyn SearchableItemHandle>>,
     active_match_index: Option<usize>,
-    active_searchable_item_subscription: Option<Subscription>,
+    #[cfg(target_os = "macos")]
+    active_searchable_item_subscriptions: Option<[Subscription; 2]>,
+    #[cfg(not(target_os = "macos"))]
+    active_searchable_item_subscriptions: Option<Subscription>,
     active_search: Option<Arc<SearchQuery>>,
     searchable_items_with_matches: HashMap<Box<dyn WeakSearchableItemHandle>, AnyVec<dyn Send>>,
     pending_search: Option<Task<()>>,
@@ -472,7 +475,7 @@ impl ToolbarItemView for BufferSearchBar {
         cx: &mut Context<Self>,
     ) -> ToolbarItemLocation {
         cx.notify();
-        self.active_searchable_item_subscription.take();
+        self.active_searchable_item_subscriptions.take();
         self.active_searchable_item.take();
 
         self.pending_search.take();
@@ -482,18 +485,58 @@ impl ToolbarItemView for BufferSearchBar {
         {
             let this = cx.entity().downgrade();
 
-            self.active_searchable_item_subscription =
-                Some(searchable_item_handle.subscribe_to_search_events(
-                    window,
-                    cx,
-                    Box::new(move |search_event, window, cx| {
-                        if let Some(this) = this.upgrade() {
-                            this.update(cx, |this, cx| {
-                                this.on_active_searchable_item_event(search_event, window, cx)
-                            });
+            let search_event_subscription = searchable_item_handle.subscribe_to_search_events(
+                window,
+                cx,
+                Box::new(move |search_event, window, cx| {
+                    if let Some(this) = this.upgrade() {
+                        this.update(cx, |this, cx| {
+                            this.on_active_searchable_item_event(search_event, window, cx)
+                        });
+                    }
+                }),
+            );
+
+            #[cfg(target_os = "macos")]
+            {
+                let item_focus_handle = searchable_item_handle.item_focus_handle(cx);
+
+                self.active_searchable_item_subscriptions = Some([
+                    search_event_subscription,
+                    cx.on_focus(&item_focus_handle, window, |this, window, cx| {
+                        if this.query_editor_focused || this.replacement_editor_focused {
+                            // no need to read pasteboard since focus came from toolbar
+                            return;
                         }
+
+                        cx.defer_in(window, |this, window, cx| {
+                            if let Some(item) = cx.read_from_find_pasteboard()
+                                && let Some(text) = item.text()
+                            {
+                                if this.query(cx) != text {
+                                    let search_options = item
+                                        .metadata()
+                                        .and_then(|m| m.parse().ok())
+                                        .and_then(SearchOptions::from_bits)
+                                        .unwrap_or(this.search_options);
+
+                                    drop(this.search(
+                                        &text,
+                                        Some(search_options),
+                                        true,
+                                        window,
+                                        cx,
+                                    ));
+                                }
+                            }
+                        });
                     }),
-                ));
+                ]);
+            }
+            #[cfg(not(target_os = "macos"))]
+            {
+                self.active_searchable_item_subscriptions = Some(search_event_subscription);
+            }
 
             let is_project_search = searchable_item_handle.supported_options(cx).find_in_results;
             self.active_searchable_item = Some(searchable_item_handle);
@@ -663,7 +706,7 @@ impl BufferSearchBar {
             replacement_editor,
             replacement_editor_focused: false,
             active_searchable_item: None,
-            active_searchable_item_subscription: None,
+            active_searchable_item_subscriptions: None,
             active_match_index: None,
             searchable_items_with_matches: Default::default(),
             default_options: search_options,
@@ -904,11 +947,21 @@ impl BufferSearchBar {
             });
             self.set_search_options(options, cx);
             self.clear_matches(window, cx);
+            #[cfg(target_os = "macos")]
+            self.update_find_pasteboard(cx);
             cx.notify();
         }
         self.update_matches(!updated, add_to_history, window, cx)
     }
 
+    #[cfg(target_os = "macos")]
+    pub fn update_find_pasteboard(&mut self, cx: &mut App) {
+        cx.write_to_find_pasteboard(gpui::ClipboardItem::new_string_with_metadata(
+            self.query(cx),
+            self.search_options.bits().to_string(),
+        ));
+    }
+
     pub fn focus_editor(&mut self, _: &FocusEditor, window: &mut Window, cx: &mut Context<Self>) {
         if let Some(active_editor) = self.active_searchable_item.as_ref() {
             let handle = active_editor.item_focus_handle(cx);
@@ -1098,11 +1151,12 @@ impl BufferSearchBar {
                 cx.spawn_in(window, async move |this, cx| {
                     if search.await.is_ok() {
                         this.update_in(cx, |this, window, cx| {
-                            this.activate_current_match(window, cx)
-                        })
-                    } else {
-                        Ok(())
+                            this.activate_current_match(window, cx);
+                            #[cfg(target_os = "macos")]
+                            this.update_find_pasteboard(cx);
+                        })?;
                     }
+                    anyhow::Ok(())
                 })
                 .detach_and_log_err(cx);
             }
@@ -1293,6 +1347,7 @@ impl BufferSearchBar {
                                 .insert(active_searchable_item.downgrade(), matches);
 
                             this.update_match_index(window, cx);
+
                             if add_to_history {
                                 this.search_history
                                     .add(&mut this.search_history_cursor, query_text);