From a37caa221966fe8ddf4dec111bdda27e6c9b0445 Mon Sep 17 00:00:00 2001 From: Jake Date: Wed, 22 Apr 2026 09:05:40 +0100 Subject: [PATCH] fix(cr-2026-04-22): paste_text_replacing snapshots and restores prior clipboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MAJOR from the 2026-04-22 review (paste.rs:181-217): unlike paste_text, the replace flow did not snapshot the user's clipboard before writing the raw transcript. A successful revert left the raw transcript on the clipboard and destroyed whatever the user had copied before invoking it — inconsistent with paste_text's Handy-#921 contract (brief item #3). Extracts the snapshot+restore pattern into two helpers shared between paste_text and paste_text_replacing: - snapshot_clipboard_text() -> Option - schedule_clipboard_restore(prior, transcript) Both paste_text and paste_text_replacing now run the same capture-before-stomp, restore-after-fire sequence. The restore- guard (only restore if clipboard still holds the transcript we set) is shared too, so a user who copies something new within the 300 ms window is still respected. DRY: removes ~15 lines of inline clipboard bookkeeping from paste_text while making the replace flow identical. --- src-tauri/src/commands/paste.rs | 66 ++++++++++++++++++++------------- 1 file changed, 41 insertions(+), 25 deletions(-) diff --git a/src-tauri/src/commands/paste.rs b/src-tauri/src/commands/paste.rs index 02d0b20..5703ae8 100644 --- a/src-tauri/src/commands/paste.rs +++ b/src-tauri/src/commands/paste.rs @@ -72,18 +72,7 @@ pub async fn paste_text(app: tauri::AppHandle, text: String) -> Result = match Clipboard::new() { - Ok(mut cb) => cb.get_text().ok(), - Err(_) => None, - }; + let prior_clipboard = snapshot_clipboard_text(); match Clipboard::new().and_then(|mut cb| cb.set_text(&text)) { Ok(()) => outcome.copied = true, @@ -121,25 +110,40 @@ 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); - }); - } + schedule_clipboard_restore(prior_clipboard, text); } Ok(outcome) } +/// Snapshot the clipboard's current text, or `None` when the clipboard +/// holds non-text content (images, files, empty). Shared between +/// `paste_text` and `paste_text_replacing` so both halves of the +/// dictation flow obey the "never silently clobber the user's +/// clipboard" contract from Handy #921 (brief item #3). +fn snapshot_clipboard_text() -> Option { + match Clipboard::new() { + Ok(mut cb) => cb.get_text().ok(), + Err(_) => None, + } +} + +/// Fire-and-forget: restore `prior` to the clipboard after +/// `CLIPBOARD_RESTORE_MS`, but only if the clipboard still holds the +/// `transcript` we set (i.e., the user hasn't copied something new in +/// the window). No-op when `prior` is `None` — nothing safe to +/// restore. +fn schedule_clipboard_restore(prior: Option, transcript: String) { + let Some(prior) = prior else { + return; + }; + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(CLIPBOARD_RESTORE_MS)).await; + restore_prior_clipboard(&prior, &transcript); + }); +} + /// 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 @@ -189,6 +193,14 @@ pub async fn paste_text_replacing( message: None, }; + // Same clipboard-preservation contract as `paste_text`: snapshot + // the user's prior clipboard before we write the raw transcript, + // restore it in the background after the paste keystroke fires. + // Without this, a successful revert leaves the raw transcript on + // the clipboard permanently — stomping whatever the user had + // copied before invoking replace-with-raw. + let prior_clipboard = snapshot_clipboard_text(); + match Clipboard::new().and_then(|mut cb| cb.set_text(&text)) { Ok(()) => outcome.copied = true, Err(err) => { @@ -214,6 +226,10 @@ pub async fn paste_text_replacing( Err(err) => outcome.message = Some(err), } + if outcome.pasted { + schedule_clipboard_restore(prior_clipboard, text); + } + Ok(outcome) }