Fix multi-cursor copy/paste on linux (#13523)

Conrad Irwin and Mikayla Maki created

The clipboard library we use for X11 doesn't yet support multiple
formats on the clipboard, so for now we just store this in memory for
the current zed process, as we do for Wayland.

Fixes: #11971

Release Notes:

- N/A

---------

Co-authored-by: Mikayla Maki <mikayla@zed.dev>

Change summary

crates/gpui/src/platform/linux/wayland/client.rs    | 22 +-------
crates/gpui/src/platform/linux/wayland/clipboard.rs | 34 +++++++-------
crates/gpui/src/platform/linux/x11/client.rs        | 25 +++++++++-
crates/gpui/src/platform/linux/x11/window.rs        |  1 
4 files changed, 42 insertions(+), 40 deletions(-)

Detailed changes

crates/gpui/src/platform/linux/wayland/client.rs 🔗

@@ -671,7 +671,7 @@ impl LinuxClient for WaylandClient {
             return;
         };
         if state.mouse_focused_window.is_some() || state.keyboard_focused_window.is_some() {
-            state.clipboard.set_primary(item.text);
+            state.clipboard.set_primary(item);
             let serial = state.serial_tracker.get(SerialKind::KeyPress);
             let data_source = primary_selection_manager.create_source(&state.globals.qh, ());
             data_source.offer(state.clipboard.self_mime());
@@ -689,7 +689,7 @@ impl LinuxClient for WaylandClient {
             return;
         };
         if state.mouse_focused_window.is_some() || state.keyboard_focused_window.is_some() {
-            state.clipboard.set(item.text);
+            state.clipboard.set(item);
             let serial = state.serial_tracker.get(SerialKind::KeyPress);
             let data_source = data_device_manager.create_data_source(&state.globals.qh, ());
             data_source.offer(state.clipboard.self_mime());
@@ -699,25 +699,11 @@ impl LinuxClient for WaylandClient {
     }
 
     fn read_from_primary(&self) -> Option<crate::ClipboardItem> {
-        self.0
-            .borrow_mut()
-            .clipboard
-            .read_primary()
-            .map(|s| crate::ClipboardItem {
-                text: s,
-                metadata: None,
-            })
+        self.0.borrow_mut().clipboard.read_primary()
     }
 
     fn read_from_clipboard(&self) -> Option<crate::ClipboardItem> {
-        self.0
-            .borrow_mut()
-            .clipboard
-            .read()
-            .map(|s| crate::ClipboardItem {
-                text: s,
-                metadata: None,
-            })
+        self.0.borrow_mut().clipboard.read()
     }
 
     fn active_window(&self) -> Option<AnyWindowHandle> {

crates/gpui/src/platform/linux/wayland/clipboard.rs 🔗

@@ -9,7 +9,7 @@ use filedescriptor::Pipe;
 use wayland_client::{protocol::wl_data_offer::WlDataOffer, Connection};
 use wayland_protocols::wp::primary_selection::zv1::client::zwp_primary_selection_offer_v1::ZwpPrimarySelectionOfferV1;
 
-use crate::{platform::linux::platform::read_fd, WaylandClientStatePtr};
+use crate::{platform::linux::platform::read_fd, ClipboardItem, WaylandClientStatePtr};
 
 pub(crate) const TEXT_MIME_TYPE: &str = "text/plain;charset=utf-8";
 pub(crate) const FILE_LIST_MIME_TYPE: &str = "text/uri-list";
@@ -23,13 +23,13 @@ pub(crate) struct Clipboard {
     self_mime: String,
 
     // Internal clipboard
-    contents: Option<String>,
-    primary_contents: Option<String>,
+    contents: Option<ClipboardItem>,
+    primary_contents: Option<ClipboardItem>,
 
     // External clipboard
-    cached_read: Option<String>,
+    cached_read: Option<ClipboardItem>,
     current_offer: Option<DataOffer<WlDataOffer>>,
-    cached_primary_read: Option<String>,
+    cached_primary_read: Option<ClipboardItem>,
     current_primary_offer: Option<DataOffer<ZwpPrimarySelectionOfferV1>>,
 }
 
@@ -89,12 +89,12 @@ impl Clipboard {
         }
     }
 
-    pub fn set(&mut self, text: String) {
-        self.contents = Some(text);
+    pub fn set(&mut self, item: ClipboardItem) {
+        self.contents = Some(item);
     }
 
-    pub fn set_primary(&mut self, text: String) {
-        self.primary_contents = Some(text);
+    pub fn set_primary(&mut self, item: ClipboardItem) {
+        self.primary_contents = Some(item);
     }
 
     pub fn set_offer(&mut self, data_offer: Option<DataOffer<WlDataOffer>>) {
@@ -113,17 +113,17 @@ impl Clipboard {
 
     pub fn send(&self, _mime_type: String, fd: OwnedFd) {
         if let Some(contents) = &self.contents {
-            self.send_internal(fd, contents.as_bytes().to_owned());
+            self.send_internal(fd, contents.text.as_bytes().to_owned());
         }
     }
 
     pub fn send_primary(&self, _mime_type: String, fd: OwnedFd) {
         if let Some(primary_contents) = &self.primary_contents {
-            self.send_internal(fd, primary_contents.as_bytes().to_owned());
+            self.send_internal(fd, primary_contents.text.as_bytes().to_owned());
         }
     }
 
-    pub fn read(&mut self) -> Option<String> {
+    pub fn read(&mut self) -> Option<ClipboardItem> {
         let offer = self.current_offer.clone()?;
         if let Some(cached) = self.cached_read.clone() {
             return Some(cached);
@@ -145,8 +145,8 @@ impl Clipboard {
 
         match unsafe { read_fd(fd) } {
             Ok(v) => {
-                self.cached_read = Some(v.clone());
-                Some(v)
+                self.cached_read = Some(ClipboardItem::new(v));
+                self.cached_read.clone()
             }
             Err(err) => {
                 log::error!("error reading clipboard pipe: {err:?}");
@@ -155,7 +155,7 @@ impl Clipboard {
         }
     }
 
-    pub fn read_primary(&mut self) -> Option<String> {
+    pub fn read_primary(&mut self) -> Option<ClipboardItem> {
         let offer = self.current_primary_offer.clone()?;
         if let Some(cached) = self.cached_primary_read.clone() {
             return Some(cached);
@@ -177,8 +177,8 @@ impl Clipboard {
 
         match unsafe { read_fd(fd) } {
             Ok(v) => {
-                self.cached_primary_read = Some(v.clone());
-                Some(v)
+                self.cached_primary_read = Some(ClipboardItem::new(v.clone()));
+                self.cached_primary_read.clone()
             }
             Err(err) => {
                 log::error!("error reading clipboard pipe: {err:?}");

crates/gpui/src/platform/linux/x11/client.rs 🔗

@@ -29,9 +29,9 @@ use xkbcommon::xkb as xkbc;
 use crate::platform::linux::LinuxClient;
 use crate::platform::{LinuxCommon, PlatformWindow};
 use crate::{
-    modifiers_from_xinput_info, point, px, AnyWindowHandle, Bounds, CursorStyle, DisplayId,
-    Keystroke, Modifiers, ModifiersChangedEvent, Pixels, PlatformDisplay, PlatformInput, Point,
-    ScrollDelta, Size, TouchPhase, WindowParams, X11Window,
+    modifiers_from_xinput_info, point, px, AnyWindowHandle, Bounds, ClipboardItem, CursorStyle,
+    DisplayId, Keystroke, Modifiers, ModifiersChangedEvent, Pixels, PlatformDisplay, PlatformInput,
+    Point, ScrollDelta, Size, TouchPhase, WindowParams, X11Window,
 };
 
 use super::{
@@ -129,6 +129,7 @@ pub struct X11ClientState {
 
     pub(crate) common: LinuxCommon,
     pub(crate) clipboard: x11_clipboard::Clipboard,
+    pub(crate) clipboard_item: Option<ClipboardItem>,
 }
 
 #[derive(Clone)]
@@ -413,6 +414,7 @@ impl X11Client {
             scroll_y: None,
 
             clipboard,
+            clipboard_item: None,
         })))
     }
 
@@ -1097,7 +1099,7 @@ impl LinuxClient for X11Client {
     }
 
     fn write_to_clipboard(&self, item: crate::ClipboardItem) {
-        let state = self.0.borrow_mut();
+        let mut state = self.0.borrow_mut();
         state
             .clipboard
             .store(
@@ -1106,6 +1108,7 @@ impl LinuxClient for X11Client {
                 item.text().as_bytes(),
             )
             .ok();
+        state.clipboard_item.replace(item);
     }
 
     fn read_from_primary(&self) -> Option<crate::ClipboardItem> {
@@ -1127,6 +1130,20 @@ impl LinuxClient for X11Client {
 
     fn read_from_clipboard(&self) -> Option<crate::ClipboardItem> {
         let state = self.0.borrow_mut();
+        // if the last copy was from this app, return our cached item
+        // which has metadata attached.
+        if state
+            .clipboard
+            .setter
+            .connection
+            .get_selection_owner(state.clipboard.setter.atoms.clipboard)
+            .ok()
+            .and_then(|r| r.reply().ok())
+            .map(|reply| reply.owner == state.clipboard.setter.window)
+            .unwrap_or(false)
+        {
+            return state.clipboard_item.clone();
+        }
         state
             .clipboard
             .load(