From 084b5d21f67cdbe06edd2822ce34e58b3cd774f7 Mon Sep 17 00:00:00 2001 From: Baris Palaska Date: Mon, 13 Jul 2026 15:22:44 +0100 Subject: [PATCH] gpui_web: Implement clipboard write and paste (#60890) # Objective write_to_clipboard was a no-op and read_from_clipboard returned None. ## Solution Writes go through the browser's asynchronous Clipboard API, fire-and-forget, invoked synchronously inside the user's input event so the user-activation requirement holds. Reads cannot be implemented the same way: the async clipboard read API cannot fit read_from_clipboard's synchronous signature. Paste is instead delivered by a DOM paste listener on the hidden input, whose ClipboardEvent exposes clipboardData synchronously; the text feeds the active input handler. This covers keyboard-, menu-bar- and context-menu-initiated paste. Note the keyboard path requires the paste chord's keydown to reach the browser unconsumed (apps should not bind cmd/ctrl-v on web). ## Testing Tested locally ## Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content adheres to Zed's UI standards ([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) and [icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md) guidelines) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - N/A --- crates/gpui_web/Cargo.toml | 2 ++ crates/gpui_web/src/events.rs | 27 +++++++++++++++++++++++++++ crates/gpui_web/src/platform.rs | 10 +++++++++- 3 files changed, 38 insertions(+), 1 deletion(-) diff --git a/crates/gpui_web/Cargo.toml b/crates/gpui_web/Cargo.toml index 5980fa5e855..6ceafc0f3bc 100644 --- a/crates/gpui_web/Cargo.toml +++ b/crates/gpui_web/Cargo.toml @@ -35,6 +35,8 @@ raw-window-handle = "0.6" wasm_thread = { version = "0.3", features = ["es_modules"], optional = true } web-sys = { version = "0.3", features = [ "console", + "Clipboard", + "ClipboardEvent", "CompositionEvent", "CssStyleDeclaration", "DataTransfer", diff --git a/crates/gpui_web/src/events.rs b/crates/gpui_web/src/events.rs index 46be646cb56..5956331ea81 100644 --- a/crates/gpui_web/src/events.rs +++ b/crates/gpui_web/src/events.rs @@ -64,6 +64,7 @@ impl WebWindowInner { self.register_dragleave(), self.register_key_down(), self.register_key_up(), + self.register_paste(), self.register_composition_start(), self.register_composition_update(), self.register_composition_end(), @@ -427,6 +428,32 @@ impl WebWindowInner { }) } + /// Paste is delivered through the DOM `paste` event rather than + /// `Platform::read_from_clipboard`: the browser's asynchronous clipboard + /// read API cannot fit that synchronous signature, while `ClipboardEvent` + /// exposes `clipboardData` synchronously inside the event. It fires for + /// any browser-initiated paste (keyboard, menu bar, context menu). + fn register_paste(self: &Rc) -> Closure { + let this = Rc::clone(self); + self.listen_input("paste", move |event: JsValue| { + let event: web_sys::ClipboardEvent = event.unchecked_into(); + let Some(clipboard_data) = event.clipboard_data() else { + return; + }; + let Ok(text) = clipboard_data.get_data("text/plain") else { + return; + }; + if text.is_empty() { + return; + } + + event.prevent_default(); + this.with_input_handler(|handler| { + handler.replace_text_in_range(None, &text); + }); + }) + } + fn register_composition_start(self: &Rc) -> Closure { let this = Rc::clone(self); self.listen_input("compositionstart", move |_event: JsValue| { diff --git a/crates/gpui_web/src/platform.rs b/crates/gpui_web/src/platform.rs index c39723ea5b2..f70520d629d 100644 --- a/crates/gpui_web/src/platform.rs +++ b/crates/gpui_web/src/platform.rs @@ -342,7 +342,15 @@ impl Platform for WebPlatform { None } - fn write_to_clipboard(&self, _item: ClipboardItem) {} + fn write_to_clipboard(&self, item: ClipboardItem) { + if let Some(text) = item.text() + && let Some(window) = web_sys::window() + { + // Fire-and-forget; called synchronously inside the user's input + // event, which satisfies the browser's user-activation requirement. + drop(window.navigator().clipboard().write_text(&text)); + } + } fn write_credentials(&self, _url: &str, _username: &str, _password: &[u8]) -> Task> { Task::ready(Err(anyhow::anyhow!(