Allow metadata to be associated with text written to clipboard

Antonio Scandurra and Max Brunsfeld created

Co-Authored-By: Max Brunsfeld <max@zed.dev>

Change summary

Cargo.lock                          |  15 +++
gpui/Cargo.toml                     |   3 
gpui/src/app.rs                     |  10 +-
gpui/src/clipboard.rs               |  42 +++++++++
gpui/src/lib.rs                     |   2 
gpui/src/platform/mac/platform.rs   | 137 +++++++++++++++++++++++++++---
gpui/src/platform/mod.rs            |   6 
gpui/src/platform/test.rs           |   6 
zed/src/editor/buffer_view.rs       |  12 +-
zed/src/workspace/workspace_view.rs |   7 
10 files changed, 204 insertions(+), 36 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -924,6 +924,7 @@ dependencies = [
  "rand 0.8.3",
  "replace_with",
  "resvg",
+ "seahash",
  "serde",
  "serde_json",
  "simplelog",
@@ -1712,6 +1713,20 @@ name = "serde"
 version = "1.0.125"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "558dc50e1a5a5fa7112ca2ce4effcb321b0300c0d4ccf0776a9f60cd89031171"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.125"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b093b7a2bb58203b5da3056c05b4ec1fed827dcfdb37347a8841695263b3d06d"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
 
 [[package]]
 name = "serde_json"

gpui/Cargo.toml 🔗

@@ -18,7 +18,8 @@ pathfinder_geometry = "0.5"
 rand = "0.8.3"
 replace_with = "0.1.7"
 resvg = "0.14"
-serde = "1.0.125"
+seahash = "4.1"
+serde = { version = "1.0.125", features = ["derive"] }
 serde_json = "1.0.64"
 smallvec = "1.6.1"
 smol = "1.2"

gpui/src/app.rs 🔗

@@ -5,7 +5,7 @@ use crate::{
     platform::{self, WindowOptions},
     presenter::Presenter,
     util::post_inc,
-    AssetCache, AssetSource, FontCache, TextLayoutCache,
+    AssetCache, AssetSource, ClipboardItem, FontCache, TextLayoutCache,
 };
 use anyhow::{anyhow, Result};
 use async_std::sync::Condvar;
@@ -1212,12 +1212,12 @@ impl MutableAppContext {
         }
     }
 
-    pub fn copy(&self, text: &str) {
-        self.platform.copy(text);
+    pub fn write_to_clipboard(&self, item: ClipboardItem) {
+        self.platform.write_to_clipboard(item);
     }
 
-    pub fn paste(&self) -> Option<String> {
-        self.platform.paste()
+    pub fn read_from_clipboard(&self) -> Option<ClipboardItem> {
+        self.platform.read_from_clipboard()
     }
 }
 

gpui/src/clipboard.rs 🔗

@@ -0,0 +1,42 @@
+use seahash::SeaHasher;
+use serde::{Deserialize, Serialize};
+use std::hash::{Hash, Hasher};
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct ClipboardItem {
+    pub(crate) text: String,
+    pub(crate) metadata: Option<String>,
+}
+
+impl ClipboardItem {
+    pub fn new(text: String) -> Self {
+        Self {
+            text,
+            metadata: None,
+        }
+    }
+
+    pub fn with_metadata<T: Serialize>(mut self, metadata: T) -> Self {
+        self.metadata = Some(serde_json::to_string(&metadata).unwrap());
+        self
+    }
+
+    pub fn text(&self) -> &String {
+        &self.text
+    }
+
+    pub fn metadata<T>(&self) -> Option<T>
+    where
+        T: for<'a> Deserialize<'a>,
+    {
+        self.metadata
+            .as_ref()
+            .and_then(|m| serde_json::from_str(m).ok())
+    }
+
+    pub(crate) fn text_hash(text: &str) -> u64 {
+        let mut hasher = SeaHasher::new();
+        text.hash(&mut hasher);
+        hasher.finish()
+    }
+}

gpui/src/lib.rs 🔗

@@ -7,6 +7,8 @@ pub use assets::*;
 pub mod elements;
 pub mod font_cache;
 pub use font_cache::FontCache;
+mod clipboard;
+pub use clipboard::ClipboardItem;
 pub mod fonts;
 pub mod geometry;
 mod presenter;

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

@@ -1,5 +1,5 @@
 use super::{BoolExt as _, Dispatcher, FontSystem, Window};
-use crate::{executor, keymap::Keystroke, platform, Event, Menu, MenuItem};
+use crate::{executor, keymap::Keystroke, platform, ClipboardItem, Event, Menu, MenuItem};
 use cocoa::{
     appkit::{
         NSApplication, NSApplicationActivationPolicy::NSApplicationActivationPolicyRegular,
@@ -21,12 +21,13 @@ use ptr::null_mut;
 use std::{
     any::Any,
     cell::RefCell,
+    convert::TryInto,
     ffi::{c_void, CStr},
     os::raw::c_char,
     path::PathBuf,
     ptr,
     rc::Rc,
-    slice,
+    slice, str,
     sync::Arc,
 };
 
@@ -78,6 +79,9 @@ pub struct MacPlatform {
     fonts: Arc<FontSystem>,
     callbacks: RefCell<Callbacks>,
     menu_item_actions: RefCell<Vec<(String, Option<Box<dyn Any>>)>>,
+    pasteboard: id,
+    text_hash_pasteboard_type: id,
+    metadata_pasteboard_type: id,
 }
 
 #[derive(Default)]
@@ -97,6 +101,9 @@ impl MacPlatform {
             fonts: Arc::new(FontSystem::new()),
             callbacks: Default::default(),
             menu_item_actions: Default::default(),
+            pasteboard: unsafe { NSPasteboard::generalPasteboard(nil) },
+            text_hash_pasteboard_type: unsafe { ns_string("zed-text-hash") },
+            metadata_pasteboard_type: unsafe { ns_string("zed-metadata") },
         }
     }
 
@@ -177,6 +184,18 @@ impl MacPlatform {
 
         menu_bar
     }
+
+    unsafe fn read_from_pasteboard(&self, kind: id) -> Option<&[u8]> {
+        let data = self.pasteboard.dataForType(kind);
+        if data == nil {
+            None
+        } else {
+            Some(slice::from_raw_parts(
+                data.bytes() as *mut u8,
+                data.length() as usize,
+            ))
+        }
+    }
 }
 
 impl platform::Platform for MacPlatform {
@@ -287,28 +306,71 @@ impl platform::Platform for MacPlatform {
         }
     }
 
-    fn copy(&self, text: &str) {
+    fn write_to_clipboard(&self, item: ClipboardItem) {
         unsafe {
-            let data = NSData::dataWithBytes_length_(
+            self.pasteboard.clearContents();
+
+            let text_bytes = NSData::dataWithBytes_length_(
                 nil,
-                text.as_ptr() as *const c_void,
-                text.len() as u64,
+                item.text.as_ptr() as *const c_void,
+                item.text.len() as u64,
             );
-            let pasteboard = NSPasteboard::generalPasteboard(nil);
-            pasteboard.clearContents();
-            pasteboard.setData_forType(data, NSPasteboardTypeString);
+            self.pasteboard
+                .setData_forType(text_bytes, NSPasteboardTypeString);
+
+            if let Some(metadata) = item.metadata.as_ref() {
+                let hash_bytes = ClipboardItem::text_hash(&item.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.pasteboard
+                    .setData_forType(hash_bytes, self.text_hash_pasteboard_type);
+
+                let metadata_bytes = NSData::dataWithBytes_length_(
+                    nil,
+                    metadata.as_ptr() as *const c_void,
+                    metadata.len() as u64,
+                );
+                self.pasteboard
+                    .setData_forType(metadata_bytes, self.metadata_pasteboard_type);
+            }
         }
     }
 
-    fn paste(&self) -> Option<String> {
+    fn read_from_clipboard(&self) -> Option<ClipboardItem> {
         unsafe {
-            let pasteboard = NSPasteboard::generalPasteboard(nil);
-            let data = pasteboard.dataForType(NSPasteboardTypeString);
-            if data == nil {
-                None
+            if let Some(text_bytes) = self.read_from_pasteboard(NSPasteboardTypeString) {
+                let text = String::from_utf8_lossy(&text_bytes).to_string();
+                let hash_bytes = self
+                    .read_from_pasteboard(self.text_hash_pasteboard_type)
+                    .and_then(|bytes| bytes.try_into().ok())
+                    .map(u64::from_be_bytes);
+                let metadata_bytes = self
+                    .read_from_pasteboard(self.metadata_pasteboard_type)
+                    .and_then(|bytes| String::from_utf8(bytes.to_vec()).ok());
+
+                if let Some((hash, metadata)) = hash_bytes.zip(metadata_bytes) {
+                    if hash == ClipboardItem::text_hash(&text) {
+                        Some(ClipboardItem {
+                            text,
+                            metadata: Some(metadata),
+                        })
+                    } else {
+                        Some(ClipboardItem {
+                            text,
+                            metadata: None,
+                        })
+                    }
+                } else {
+                    Some(ClipboardItem {
+                        text,
+                        metadata: None,
+                    })
+                }
             } else {
-                let bytes = slice::from_raw_parts(data.bytes() as *mut u8, data.length() as usize);
-                Some(String::from_utf8_unchecked(bytes.to_vec()))
+                None
             }
         }
     }
@@ -406,3 +468,46 @@ extern "C" fn handle_menu_item(this: &mut Object, _: Sel, item: id) {
 unsafe fn ns_string(string: &str) -> id {
     NSString::alloc(nil).init_str(string).autorelease()
 }
+
+#[cfg(test)]
+mod tests {
+    use crate::platform::Platform;
+
+    use super::*;
+
+    #[test]
+    fn test_clipboard() {
+        let platform = build_platform();
+        assert_eq!(platform.read_from_clipboard(), None);
+
+        let item = ClipboardItem::new("1".to_string());
+        platform.write_to_clipboard(item.clone());
+        assert_eq!(platform.read_from_clipboard(), Some(item));
+
+        let item = ClipboardItem::new("2".to_string()).with_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
+                .pasteboard
+                .setData_forType(bytes, NSPasteboardTypeString);
+        }
+        assert_eq!(
+            platform.read_from_clipboard(),
+            Some(ClipboardItem::new(text_from_other_app.to_string()))
+        );
+    }
+
+    fn build_platform() -> MacPlatform {
+        let mut platform = MacPlatform::new();
+        platform.pasteboard = unsafe { NSPasteboard::pasteboardWithUniqueName(nil) };
+        platform
+    }
+}

gpui/src/platform/mod.rs 🔗

@@ -15,7 +15,7 @@ use crate::{
         vector::Vector2F,
     },
     text_layout::Line,
-    Menu, Scene,
+    ClipboardItem, Menu, Scene,
 };
 use async_task::Runnable;
 pub use event::Event;
@@ -42,8 +42,8 @@ pub trait Platform {
     fn key_window_id(&self) -> Option<usize>;
     fn prompt_for_paths(&self, options: PathPromptOptions) -> Option<Vec<PathBuf>>;
     fn quit(&self);
-    fn copy(&self, text: &str);
-    fn paste(&self) -> Option<String>;
+    fn write_to_clipboard(&self, item: ClipboardItem);
+    fn read_from_clipboard(&self) -> Option<ClipboardItem>;
     fn set_menus(&self, menus: Vec<Menu>);
 }
 

gpui/src/platform/test.rs 🔗

@@ -2,6 +2,8 @@ use pathfinder_geometry::vector::Vector2F;
 use std::sync::Arc;
 use std::{any::Any, rc::Rc};
 
+use crate::ClipboardItem;
+
 struct Platform {
     dispatcher: Arc<dyn super::Dispatcher>,
     fonts: Arc<dyn super::FontSystem>,
@@ -72,9 +74,9 @@ impl super::Platform for Platform {
         None
     }
 
-    fn copy(&self, _: &str) {}
+    fn write_to_clipboard(&self, _: ClipboardItem) {}
 
-    fn paste(&self) -> Option<String> {
+    fn read_from_clipboard(&self) -> Option<ClipboardItem> {
         None
     }
 }

zed/src/editor/buffer_view.rs 🔗

@@ -6,8 +6,8 @@ use crate::{settings::Settings, watch, workspace};
 use anyhow::Result;
 use futures_core::future::LocalBoxFuture;
 use gpui::{
-    fonts::Properties as FontProperties, keymap::Binding, text_layout, AppContext, Element,
-    ElementBox, Entity, FontCache, ModelHandle, MutableAppContext, View, ViewContext,
+    fonts::Properties as FontProperties, keymap::Binding, text_layout, AppContext, ClipboardItem,
+    Element, ElementBox, Entity, FontCache, ModelHandle, MutableAppContext, View, ViewContext,
     WeakViewHandle,
 };
 use gpui::{geometry::vector::Vector2F, TextLayoutCache};
@@ -479,7 +479,7 @@ impl BufferView {
         self.insert(&String::new(), ctx);
         self.end_transaction(ctx);
 
-        ctx.app_mut().copy(&text);
+        ctx.app_mut().write_to_clipboard(ClipboardItem::new(text));
     }
 
     pub fn copy(&mut self, _: &(), ctx: &mut ViewContext<Self>) {
@@ -496,12 +496,12 @@ impl BufferView {
             text.extend(buffer.text_for_range(start..end).unwrap());
         }
 
-        ctx.app_mut().copy(&text);
+        ctx.app_mut().write_to_clipboard(ClipboardItem::new(text));
     }
 
     pub fn paste(&mut self, _: &(), ctx: &mut ViewContext<Self>) {
-        if let Some(text) = ctx.app_mut().paste() {
-            self.insert(&text, ctx);
+        if let Some(item) = ctx.app_mut().read_from_clipboard() {
+            self.insert(item.text(), ctx);
         }
     }
 

zed/src/workspace/workspace_view.rs 🔗

@@ -3,7 +3,7 @@ use crate::{settings::Settings, watch};
 use futures_core::future::LocalBoxFuture;
 use gpui::{
     color::rgbu, elements::*, json::to_string_pretty, keymap::Binding, AnyViewHandle, AppContext,
-    Entity, ModelHandle, MutableAppContext, View, ViewContext, ViewHandle,
+    ClipboardItem, Entity, ModelHandle, MutableAppContext, View, ViewContext, ViewHandle,
 };
 use log::{error, info};
 use std::{collections::HashSet, path::PathBuf};
@@ -258,10 +258,11 @@ impl WorkspaceView {
     pub fn debug_elements(&mut self, _: &(), ctx: &mut ViewContext<Self>) {
         match to_string_pretty(&ctx.debug_elements()) {
             Ok(json) => {
-                ctx.app_mut().copy(&json);
+                let kib = json.len() as f32 / 1024.;
+                ctx.app_mut().write_to_clipboard(ClipboardItem::new(json));
                 log::info!(
                     "copied {:.1} KiB of element debug JSON to the clipboard",
-                    json.len() as f32 / 1024.
+                    kib
                 );
             }
             Err(error) => {