fix(tui): harvest v0.8.43 candidate fixes

- grep_files now respects cancellation token (#1839, thanks @LING71671)
- Ctrl+Z restores last cleared composer draft (#1911, thanks @LING71671)
- Clipboard works on non-wlroots Wayland via wl-copy (#1938, thanks @ousamabenyounes)
This commit is contained in:
Hunter Bown
2026-05-24 03:12:04 -05:00
parent 77432a218b
commit 8878ac07af
4 changed files with 207 additions and 4 deletions
+66 -3
View File
@@ -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<GrepMatch> = 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<Vec<PathBuf>, 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<PathBuf>,
) -> 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;
+74
View File
@@ -881,6 +881,7 @@ pub struct ComposerState {
pub paste_burst: PasteBurst,
pub input_history: Vec<String>,
pub draft_history: VecDeque<String>,
pub clear_undo_buffer: Option<String>,
pub history_index: Option<usize>,
pub(crate) history_navigation_draft: Option<InputHistoryDraft>,
pub composer_history_search: Option<ComposerHistorySearch>,
@@ -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());
+62 -1
View File
@@ -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");
+5
View File
@@ -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) =>
{