diff --git a/crates/tui/src/tui/clipboard.rs b/crates/tui/src/tui/clipboard.rs index 63a743bb..123b1d3c 100644 --- a/crates/tui/src/tui/clipboard.rs +++ b/crates/tui/src/tui/clipboard.rs @@ -7,10 +7,15 @@ //! endpoint, so we materialize the bytes to disk instead of base64-embedding //! them in the request). +#[cfg(any(not(test), all(test, unix)))] +use std::io::Write; #[cfg(not(test))] -use std::io::{self, IsTerminal, Write}; +use std::io::{self, IsTerminal}; use std::path::{Path, PathBuf}; -#[cfg(all(any(target_os = "macos", target_os = "windows"), not(test)))] +#[cfg(any( + all(test, unix), + all(any(target_os = "macos", target_os = "windows"), not(test)) +))] use std::process::{Command, Stdio}; use std::time::{SystemTime, UNIX_EPOCH}; @@ -161,43 +166,47 @@ impl ClipboardHandler { #[cfg(all(target_os = "macos", not(test)))] fn write_text_with_pbcopy(text: &str) -> Result<()> { - let mut child = Command::new("pbcopy") - .stdin(Stdio::piped()) - .spawn() - .map_err(|e| anyhow::anyhow!("Failed to run pbcopy: {e}"))?; - if let Some(mut stdin) = child.stdin.take() { - stdin - .write_all(text.as_bytes()) - .map_err(|e| anyhow::anyhow!("Failed to write to pbcopy: {e}"))?; - } - let status = child - .wait() - .map_err(|e| anyhow::anyhow!("Failed to wait for pbcopy: {e}"))?; - if status.success() { - return Ok(()); - } - Err(anyhow::anyhow!("pbcopy failed")) + write_text_with_stdin_command("pbcopy", &[], text, "pbcopy") } #[cfg(all(target_os = "windows", not(test)))] fn write_text_with_set_clipboard(text: &str) -> Result<()> { - let mut child = Command::new("powershell.exe") - .args(["-NoProfile", "-Command", "Set-Clipboard -Value $input"]) + write_text_with_stdin_command( + "powershell.exe", + &["-NoProfile", "-Command", "Set-Clipboard -Value $input"], + text, + "Set-Clipboard", + ) +} + +#[cfg(any( + all(test, unix), + all(any(target_os = "macos", target_os = "windows"), not(test)) +))] +fn write_text_with_stdin_command( + program: &str, + args: &[&str], + text: &str, + label: &str, +) -> Result<()> { + let mut child = Command::new(program) + .args(args) .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) .spawn() - .map_err(|e| anyhow::anyhow!("Failed to run Set-Clipboard: {e}"))?; + .map_err(|e| anyhow::anyhow!("Failed to run {label}: {e}"))?; if let Some(mut stdin) = child.stdin.take() { stdin .write_all(text.as_bytes()) - .map_err(|e| anyhow::anyhow!("Failed to write to Set-Clipboard: {e}"))?; + .map_err(|e| anyhow::anyhow!("Failed to write to {label}: {e}"))?; } - let status = child - .wait() - .map_err(|e| anyhow::anyhow!("Failed to wait for Set-Clipboard: {e}"))?; - if status.success() { - return Ok(()); - } - Err(anyhow::anyhow!("Set-Clipboard failed")) + let _ = std::thread::Builder::new() + .name("clipboard-wait".to_string()) + .spawn(move || { + let _ = child.wait(); + }); + Ok(()) } #[cfg(not(test))] @@ -325,6 +334,48 @@ mod tests { assert_eq!(&header[..8], b"\x89PNG\r\n\x1a\n"); } + #[cfg(unix)] + #[test] + fn stdin_clipboard_command_returns_before_helper_exits() { + use std::time::{Duration, Instant}; + + let dir = tempfile::tempdir().unwrap(); + let marker = dir.path().join("clipboard.txt"); + let script = dir.path().join("slow-clipboard.sh"); + std::fs::write(&script, "#!/bin/sh\ncat > \"$1\"\nsleep 1\n").unwrap(); + + use std::os::unix::fs::PermissionsExt; + let mut permissions = std::fs::metadata(&script).unwrap().permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&script, permissions).unwrap(); + + let started = Instant::now(); + write_text_with_stdin_command( + script.to_str().unwrap(), + &[marker.to_str().unwrap()], + "copied", + "test-clipboard", + ) + .unwrap(); + assert!( + started.elapsed() < Duration::from_millis(250), + "clipboard helper wait leaked onto caller path" + ); + + let deadline = Instant::now() + Duration::from_secs(2); + let mut last_body = String::new(); + while Instant::now() < deadline { + if let Ok(body) = std::fs::read_to_string(&marker) { + if body == "copied" { + return; + } + last_body = body; + } + std::thread::sleep(Duration::from_millis(20)); + } + panic!("clipboard helper did not receive stdin; last body: {last_body:?}"); + } + #[test] fn pasted_image_labels_format_correctly() { let p = PastedImage {