fix(cr-2026-04-22): paste_text_replacing snapshots and restores prior clipboard

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<String>
- 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.
This commit is contained in:
2026-04-22 09:05:40 +01:00
parent 0ea230fef4
commit a37caa2219

View File

@@ -72,18 +72,7 @@ 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,
};
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<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);
});
}
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<String> {
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<String>, 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)
}