From f3a0673eaafd32ed8432df7a0c0571ca73c92e40 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 21 Apr 2026 11:36:46 +0000 Subject: [PATCH] feat(B.1 #3): snapshot prior clipboard and restore 300 ms after paste MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends commands/paste.rs so that when auto-paste fires we: 1. capture the user's existing clipboard text BEFORE overwriting it with the transcript (via arboard::Clipboard::get_text — a non-text clipboard returns None and the restore step is skipped, which keeps images / files safe), 2. after the paste keystroke lands, sleep 300 ms, 3. restore the snapshot only if the clipboard still holds the transcript we wrote — respects any Cmd+C the user did in the 300 ms window. Moves the decision to a pure should_restore(current, transcript) helper with four unit tests. The existing paste-matrix regression tests are unchanged. Matches Handy #921 (workstream-B brief item #3). Coexists with the Wayland preview-hide dance already in this file, and doesn't run when the paste keystroke fails (transcript stays on the clipboard for the user's own paste). Co-authored-by: jars --- src-tauri/src/commands/paste.rs | 94 ++++++++++++++++++++++++++++++++- 1 file changed, 93 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/commands/paste.rs b/src-tauri/src/commands/paste.rs index 4c513b5..4b6917d 100644 --- a/src-tauri/src/commands/paste.rs +++ b/src-tauri/src/commands/paste.rs @@ -21,6 +21,13 @@ use arboard::Clipboard; use serde::Serialize; use tauri::Manager; +/// Window after the paste keystroke at which we restore the user's +/// prior clipboard content. 300 ms is enough for even a slow Wayland +/// compositor to have fully delivered the synthesised Ctrl+V to the +/// focused app; longer risks the user manually pasting again and +/// stomping themselves. See Handy #921. +const CLIPBOARD_RESTORE_MS: u64 = 300; + /// Compositor settle time after hiding the preview overlay before firing /// the paste keystroke. Empirically ~80ms is enough on KWin + Mutter /// Wayland for focus to return to the previously-focused app; shorter @@ -59,6 +66,19 @@ pub async fn paste_text(app: tauri::AppHandle, text: String) -> Result = match Clipboard::new() { + Ok(mut cb) => cb.get_text().ok(), + Err(_) => None, + }; + match Clipboard::new().and_then(|mut cb| cb.set_text(&text)) { Ok(()) => outcome.copied = true, Err(err) => { @@ -77,9 +97,53 @@ pub async fn paste_text(app: tauri::AppHandle, text: String) -> Result outcome.message = Some(err), } + // Fire-and-forget: restore the prior clipboard in the background + // after CLIPBOARD_RESTORE_MS, regardless of whether the paste + // succeeded. If paste failed, the transcript is still on the + // clipboard and Kon's own "Copy" button remains the user's + // recovery path; overwriting it with the restore would undo that. + // So only restore when the keystroke actually fired. + if outcome.pasted { + if let Some(prior) = prior_clipboard.clone() { + let transcript_clone = text.clone(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(CLIPBOARD_RESTORE_MS)).await; + restore_prior_clipboard(&prior, &transcript_clone); + }); + } + } + Ok(outcome) } +/// Write `prior` back to the clipboard only if the current clipboard +/// content is still the transcript we just set — i.e. the user hasn't +/// already copied something new themselves in the last 300 ms. This +/// avoids stomping a deliberate Cmd+C that happened right after the +/// paste. +fn restore_prior_clipboard(prior: &str, transcript: &str) { + let mut cb = match Clipboard::new() { + Ok(cb) => cb, + Err(_) => return, + }; + + if should_restore(cb.get_text().ok().as_deref(), transcript) { + let _ = cb.set_text(prior.to_string()); + } +} + +/// Pure decision function: should we restore the prior clipboard? +/// +/// - Some(current) == transcript → yes, the clipboard still holds +/// what we wrote, so restore is safe. +/// - Some(current) != transcript → no, user has already copied +/// something else in the 300 ms window; respect it. +/// - None → couldn't read the clipboard (image, no access, etc.); +/// conservative no-op. +fn should_restore(current: Option<&str>, transcript: &str) -> bool { + matches!(current, Some(text) if text == transcript) +} + /// Hide the transcription-preview window if it's currently visible, then /// sleep a short beat so the compositor can recompute focus. No-ops when /// the window isn't registered yet (user never enabled the overlay) or @@ -262,7 +326,7 @@ fn windows_paste() -> Result { } #[cfg(all(test, target_os = "linux"))] -mod tests { +mod tests_linux { use super::pick_linux_backend_order; #[test] @@ -297,3 +361,31 @@ mod tests { ); } } + +#[cfg(test)] +mod tests_clipboard_restore { + use super::should_restore; + + #[test] + fn restores_when_clipboard_still_holds_transcript() { + assert!(should_restore(Some("dictated text"), "dictated text")); + } + + #[test] + fn does_not_restore_when_user_copied_something_else() { + assert!(!should_restore( + Some("user copied this just now"), + "dictated text" + )); + } + + #[test] + fn does_not_restore_when_clipboard_unreadable() { + assert!(!should_restore(None, "dictated text")); + } + + #[test] + fn does_not_restore_on_empty_mismatch() { + assert!(!should_restore(Some(""), "dictated text")); + } +}