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,
|
message: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Snapshot the user's existing clipboard text BEFORE we stomp it
|
let prior_clipboard = snapshot_clipboard_text();
|
||||||
// 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)) {
|
match Clipboard::new().and_then(|mut cb| cb.set_text(&text)) {
|
||||||
Ok(()) => outcome.copied = true,
|
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),
|
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 outcome.pasted {
|
||||||
if let Some(prior) = prior_clipboard.clone() {
|
schedule_clipboard_restore(prior_clipboard, text);
|
||||||
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)
|
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
|
/// 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
|
/// 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
|
/// already copied something new themselves in the last 300 ms. This
|
||||||
@@ -189,6 +193,14 @@ pub async fn paste_text_replacing(
|
|||||||
message: None,
|
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)) {
|
match Clipboard::new().and_then(|mut cb| cb.set_text(&text)) {
|
||||||
Ok(()) => outcome.copied = true,
|
Ok(()) => outcome.copied = true,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
@@ -214,6 +226,10 @@ pub async fn paste_text_replacing(
|
|||||||
Err(err) => outcome.message = Some(err),
|
Err(err) => outcome.message = Some(err),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if outcome.pasted {
|
||||||
|
schedule_clipboard_restore(prior_clipboard, text);
|
||||||
|
}
|
||||||
|
|
||||||
Ok(outcome)
|
Ok(outcome)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user