diff --git a/crates/tui/src/tools/search.rs b/crates/tui/src/tools/search.rs index c1fb5bbc..b4fc8d1f 100644 --- a/crates/tui/src/tools/search.rs +++ b/crates/tui/src/tools/search.rs @@ -13,6 +13,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use std::fs; use std::path::{Path, PathBuf}; +use tokio_util::sync::CancellationToken; /// Maximum number of results to return to avoid overwhelming output const MAX_RESULTS: usize = 100; @@ -148,8 +149,15 @@ impl ToolSpec for GrepFilesTool { // Resolve search path let search_path = context.resolve_path(path_str)?; + let cancel_token = context.cancel_token.as_ref(); + // Collect files to search - let files = collect_files(&search_path, &include_patterns, &exclude_patterns)?; + let files = collect_files( + &search_path, + &include_patterns, + &exclude_patterns, + cancel_token, + )?; // Search files let mut results: Vec = Vec::new(); @@ -157,6 +165,8 @@ impl ToolSpec for GrepFilesTool { let mut total_matches = 0; for file_path in files { + check_cancelled(cancel_token)?; + if results.len() >= max_results { break; } @@ -177,6 +187,8 @@ impl ToolSpec for GrepFilesTool { let lines: Vec<&str> = file_content.lines().collect(); for (line_idx, line) in lines.iter().enumerate() { + check_cancelled(cancel_token)?; + if regex.is_match(line) { total_matches += 1; @@ -251,15 +263,24 @@ fn collect_files( root: &Path, include_patterns: &[String], exclude_patterns: &[String], + cancel_token: Option<&CancellationToken>, ) -> Result, ToolError> { let mut files = Vec::new(); + check_cancelled(cancel_token)?; if root.is_file() { files.push(root.to_path_buf()); return Ok(files); } - collect_files_recursive(root, root, include_patterns, exclude_patterns, &mut files)?; + collect_files_recursive( + root, + root, + include_patterns, + exclude_patterns, + cancel_token, + &mut files, + )?; Ok(files) } @@ -268,8 +289,11 @@ fn collect_files_recursive( current: &Path, include_patterns: &[String], exclude_patterns: &[String], + cancel_token: Option<&CancellationToken>, files: &mut Vec, ) -> Result<(), ToolError> { + check_cancelled(cancel_token)?; + let entries = fs::read_dir(current).map_err(|e| { ToolError::execution_failed(format!( "Failed to read directory {}: {}", @@ -279,6 +303,8 @@ fn collect_files_recursive( })?; for entry in entries { + check_cancelled(cancel_token)?; + let entry = entry.map_err(|e| ToolError::execution_failed(e.to_string()))?; let path = entry.path(); let file_type = entry.file_type().map_err(|e| { @@ -302,7 +328,14 @@ fn collect_files_recursive( } if file_type.is_dir() { - collect_files_recursive(root, &path, include_patterns, exclude_patterns, files)?; + collect_files_recursive( + root, + &path, + include_patterns, + exclude_patterns, + cancel_token, + files, + )?; } else if file_type.is_file() { // Check inclusions (if any specified) if include_patterns.is_empty() || should_include(&relative_str, include_patterns) { @@ -314,6 +347,15 @@ fn collect_files_recursive( Ok(()) } +fn check_cancelled(cancel_token: Option<&CancellationToken>) -> Result<(), ToolError> { + if cancel_token.is_some_and(CancellationToken::is_cancelled) { + return Err(ToolError::execution_failed( + "search cancelled before completion", + )); + } + Ok(()) +} + /// Check if a path matches any of the exclude patterns fn should_exclude(path: &str, patterns: &[String]) -> bool { for pattern in patterns { @@ -428,6 +470,7 @@ mod tests { use serde_json::{Value, json}; use tempfile::tempdir; + use tokio_util::sync::CancellationToken; use crate::tools::spec::{ApprovalRequirement, ToolContext, ToolSpec}; @@ -639,6 +682,26 @@ mod tests { assert!(result.is_err()); } + #[tokio::test] + async fn test_grep_files_respects_cancel_token() { + let tmp = tempdir().expect("tempdir"); + fs::write(tmp.path().join("test.txt"), "needle\n").expect("write"); + let cancel_token = CancellationToken::new(); + cancel_token.cancel(); + let ctx = ToolContext::new(tmp.path().to_path_buf()).with_cancel_token(cancel_token); + + let tool = GrepFilesTool; + let err = tool + .execute(json!({"pattern": "needle"}), &ctx) + .await + .expect_err("cancelled grep should return an error"); + + assert!( + format!("{err:?}").contains("cancelled"), + "unexpected error: {err:?}" + ); + } + #[test] fn test_grep_files_tool_properties() { let tool = GrepFilesTool; diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index a6de0622..38fb063d 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -881,6 +881,7 @@ pub struct ComposerState { pub paste_burst: PasteBurst, pub input_history: Vec, pub draft_history: VecDeque, + pub clear_undo_buffer: Option, pub history_index: Option, pub(crate) history_navigation_draft: Option, pub composer_history_search: Option, @@ -912,6 +913,7 @@ impl Default for ComposerState { paste_burst: PasteBurst::default(), input_history: Vec::new(), draft_history: VecDeque::new(), + clear_undo_buffer: None, history_index: None, history_navigation_draft: None, composer_history_search: None, @@ -1728,6 +1730,7 @@ impl App { paste_burst: PasteBurst::default(), input_history, draft_history: VecDeque::new(), + clear_undo_buffer: None, history_index: None, history_navigation_draft: None, composer_history_search: None, @@ -3796,6 +3799,11 @@ impl App { pub fn stash_current_input_for_recovery(&mut self) { let draft = self.input.clone(); + if draft.trim().is_empty() { + self.clear_undo_buffer = None; + return; + } + self.clear_undo_buffer = Some(draft.clone()); self.remember_draft_for_recovery(draft); } @@ -4033,6 +4041,28 @@ impl App { true } + /// Restore the last cleared input if the composer is empty. + /// Returns `true` if the input was restored. + pub fn restore_last_cleared_input_if_empty(&mut self) -> bool { + if !self.input.is_empty() { + return false; + } + let Some(saved) = self.clear_undo_buffer.take().filter(|s| !s.is_empty()) else { + return false; + }; + + self.input = saved; + self.cursor_position = char_count(&self.input); + self.history_index = None; + self.history_navigation_draft = None; + self.selected_attachment_index = None; + self.slash_menu_selected = 0; + self.slash_menu_hidden = false; + self.needs_redraw = true; + self.clear_undo_buffer = None; + true + } + /// Composer-Enter dispatch. Returns `Some(input)` when the press should /// fire a submit; `None` when Enter was absorbed (paste-burst Enter /// suppression — see #1073). @@ -5766,6 +5796,50 @@ mod tests { ); } + #[test] + fn clear_undo_buffer_is_set_on_clear_input_recoverable() { + let mut app = App::new(test_options(false), &Config::default()); + app.input = "hello".to_string(); + app.cursor_position = 5; + + app.clear_input_recoverable(); + + assert!(app.input.is_empty()); + assert_eq!(app.clear_undo_buffer.as_deref(), Some("hello")); + } + + #[test] + fn clear_undo_buffer_is_none_when_clearing_empty_input() { + let mut app = App::new(test_options(false), &Config::default()); + assert!(app.input.is_empty()); + + app.clear_input_recoverable(); + + assert!(app.clear_undo_buffer.is_none()); + } + + #[test] + fn restore_last_cleared_input_restores_saved_draft() { + let mut app = App::new(test_options(false), &Config::default()); + app.input = "previous".to_string(); + app.cursor_position = 8; + app.clear_input_recoverable(); + assert!(app.input.is_empty()); + + let restored = app.restore_last_cleared_input_if_empty(); + assert!(restored); + assert_eq!(app.input, "previous"); + assert!(app.clear_undo_buffer.is_none()); + } + + #[test] + fn restore_last_cleared_input_does_nothing_when_composer_not_empty() { + let mut app = App::new(test_options(false), &Config::default()); + app.clear_undo_buffer = Some("old".to_string()); + app.input = "current".to_string(); + assert!(!app.restore_last_cleared_input_if_empty()); + } + #[test] fn composer_paste_flushes_pending_burst_and_normalizes_crlf() { let mut app = App::new(test_options(false), &Config::default()); diff --git a/crates/tui/src/tui/clipboard.rs b/crates/tui/src/tui/clipboard.rs index 123b1d3c..bd1c36ca 100644 --- a/crates/tui/src/tui/clipboard.rs +++ b/crates/tui/src/tui/clipboard.rs @@ -14,7 +14,10 @@ use std::io::{self, IsTerminal}; use std::path::{Path, PathBuf}; #[cfg(any( all(test, unix), - all(any(target_os = "macos", target_os = "windows"), not(test)) + all( + any(target_os = "macos", target_os = "windows", target_os = "linux"), + not(test) + ) ))] use std::process::{Command, Stdio}; use std::time::{SystemTime, UNIX_EPOCH}; @@ -136,6 +139,11 @@ impl ClipboardHandler { #[cfg(not(test))] { + #[cfg(target_os = "linux")] + if write_text_with_wlcopy(text).is_ok() { + return Ok(()); + } + self.ensure_clipboard(); if let Some(clipboard) = self.clipboard.as_mut() && clipboard.set_text(text.to_string()).is_ok() @@ -179,6 +187,37 @@ fn write_text_with_set_clipboard(text: &str) -> Result<()> { ) } +#[cfg(target_os = "linux")] +const WLCOPY_BIN: &str = "wl-copy"; + +#[cfg(all(target_os = "linux", not(test)))] +fn write_text_with_wlcopy(text: &str) -> Result<()> { + write_text_with_wlcopy_using_argv(WLCOPY_BIN, text) +} + +#[cfg(target_os = "linux")] +fn write_text_with_wlcopy_using_argv(program: &str, text: &str) -> Result<()> { + let mut child = Command::new(program) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map_err(|e| anyhow::anyhow!("Failed to run {program}: {e}"))?; + if let Some(mut stdin) = child.stdin.take() { + stdin + .write_all(text.as_bytes()) + .map_err(|e| anyhow::anyhow!("Failed to write to {program}: {e}"))?; + } + // stdin is dropped here, closing the pipe so wl-copy flushes. + let status = child + .wait() + .map_err(|e| anyhow::anyhow!("Failed to wait on {program}: {e}"))?; + if !status.success() { + bail!("{program} exited with {status}"); + } + Ok(()) +} + #[cfg(any( all(test, unix), all(any(target_os = "macos", target_os = "windows"), not(test)) @@ -388,6 +427,28 @@ mod tests { assert_eq!(p.size_label(), "235KB"); } + #[cfg(target_os = "linux")] + #[test] + fn wlcopy_helper_errors_when_binary_missing() { + let result = + write_text_with_wlcopy_using_argv("/nonexistent/path/to/wlcopy_binary_xyz", "test"); + assert!(result.is_err()); + } + + #[cfg(target_os = "linux")] + #[test] + fn wlcopy_helper_errors_when_binary_exits_nonzero() { + let result = write_text_with_wlcopy_using_argv("false", "test"); + assert!(result.is_err()); + } + + #[cfg(target_os = "linux")] + #[test] + fn wlcopy_helper_succeeds_when_binary_returns_zero() { + let result = write_text_with_wlcopy_using_argv("true", "test"); + assert!(result.is_ok()); + } + #[test] fn osc52_sequence_encodes_text_clipboard_write() { let sequence = osc52_sequence("hello", false).expect("sequence"); diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index ef3c2a38..40359036 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -3389,6 +3389,11 @@ async fn run_event_loop( KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { app.clear_input_recoverable(); } + KeyCode::Char('z') if key.modifiers.contains(KeyModifiers::CONTROL) => { + if app.restore_last_cleared_input_if_empty() { + app.status_message = Some("Restored cleared draft".to_string()); + } + } KeyCode::Char('w') | KeyCode::Char('W') if key.modifiers.contains(KeyModifiers::CONTROL) => {