feat(B.1 #3): snapshot prior clipboard and restore 300 ms after paste

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 <jakejars@users.noreply.github.com>
This commit is contained in:
Cursor Agent
2026-04-21 11:36:46 +00:00
committed by Jake
parent cc3bffa72c
commit f3a0673eaa

View File

@@ -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<PasteOutc
message: None,
};
// Snapshot the user's existing clipboard text BEFORE we stomp it
// with the transcript, so a background task can restore it after
// the paste keystroke has landed. `arboard::Clipboard::get_text`
// returns Err when the clipboard holds non-text content (images,
// files, empty) — in those cases we skip the restore step
// entirely rather than trying to coerce. This is the "never
// silently clobber the user's clipboard" contract from Handy
// #921 (Workstream B brief item #3).
let prior_clipboard: Option<String> = 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<PasteOutc
Err(err) => 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<String, String> {
}
#[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"));
}
}