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:
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user