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:
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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) =>
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user