feat: add ecc2 regex output search

This commit is contained in:
Affaan Mustafa
2026-04-09 04:00:31 -07:00
parent 8440181001
commit 8fc40da739
3 changed files with 62 additions and 20 deletions

1
ecc2/Cargo.lock generated
View File

@@ -497,6 +497,7 @@ dependencies = [
"git2", "git2",
"libc", "libc",
"ratatui", "ratatui",
"regex",
"rusqlite", "rusqlite",
"serde", "serde",
"serde_json", "serde_json",

View File

@@ -25,6 +25,7 @@ git2 = "0.20"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
toml = "0.8" toml = "0.8"
regex = "1"
# CLI # CLI
clap = { version = "4", features = ["derive"] } clap = { version = "4", features = ["derive"] }

View File

@@ -4,6 +4,7 @@ use ratatui::{
Block, Borders, Cell, HighlightSpacing, Paragraph, Row, Table, TableState, Tabs, Wrap, Block, Borders, Cell, HighlightSpacing, Paragraph, Row, Table, TableState, Tabs, Wrap,
}, },
}; };
use regex::Regex;
use std::collections::HashMap; use std::collections::HashMap;
use tokio::sync::broadcast; use tokio::sync::broadcast;
@@ -1625,6 +1626,12 @@ impl Dashboard {
return; return;
} }
if let Err(error) = compile_search_regex(&query) {
self.search_input = Some(query.clone());
self.set_operator_note(format!("invalid regex /{query}: {error}"));
return;
}
self.search_query = Some(query.clone()); self.search_query = Some(query.clone());
self.recompute_search_matches(); self.recompute_search_matches();
if self.search_matches.is_empty() { if self.search_matches.is_empty() {
@@ -2145,11 +2152,17 @@ impl Dashboard {
return; return;
}; };
let Ok(regex) = compile_search_regex(&query) else {
self.search_matches.clear();
self.selected_search_match = 0;
return;
};
self.search_matches = self self.search_matches = self
.selected_output_lines() .selected_output_lines()
.iter() .iter()
.enumerate() .enumerate()
.filter_map(|(index, line)| line_matches_query(&line.text, &query).then_some(index)) .filter_map(|(index, line)| regex.is_match(&line.text).then_some(index))
.collect(); .collect();
if self.search_matches.is_empty() { if self.search_matches.is_empty() {
@@ -2959,12 +2972,8 @@ fn configured_pane_size(cfg: &Config, layout: PaneLayout) -> u16 {
configured.clamp(MIN_PANE_SIZE_PERCENT, MAX_PANE_SIZE_PERCENT) configured.clamp(MIN_PANE_SIZE_PERCENT, MAX_PANE_SIZE_PERCENT)
} }
fn line_matches_query(text: &str, query: &str) -> bool { fn compile_search_regex(query: &str) -> Result<Regex, regex::Error> {
if query.is_empty() { Regex::new(query)
return false;
}
text.contains(query)
} }
fn highlight_output_line( fn highlight_output_line(
@@ -2977,13 +2986,15 @@ fn highlight_output_line(
return Line::from(text.to_string()); return Line::from(text.to_string());
} }
let Ok(regex) = compile_search_regex(query) else {
return Line::from(text.to_string());
};
let mut spans = Vec::new(); let mut spans = Vec::new();
let mut cursor = 0; let mut cursor = 0;
let mut search_start = 0; for matched in regex.find_iter(text) {
let start = matched.start();
while let Some(relative_index) = text[search_start..].find(query) { let end = matched.end();
let start = search_start + relative_index;
let end = start + query.len();
if start > cursor { if start > cursor {
spans.push(Span::raw(text[cursor..start].to_string())); spans.push(Span::raw(text[cursor..start].to_string()));
@@ -2999,7 +3010,6 @@ fn highlight_output_line(
}; };
spans.push(Span::styled(text[start..end].to_string(), match_style)); spans.push(Span::styled(text[start..end].to_string(), match_style));
cursor = end; cursor = end;
search_start = end;
} }
if cursor < text.len() { if cursor < text.len() {
@@ -4091,17 +4101,17 @@ diff --git a/src/next.rs b/src/next.rs
dashboard.last_output_height = 2; dashboard.last_output_height = 2;
dashboard.begin_search(); dashboard.begin_search();
for ch in "alpha".chars() { for ch in "alpha.*".chars() {
dashboard.push_search_char(ch); dashboard.push_search_char(ch);
} }
dashboard.submit_search(); dashboard.submit_search();
assert_eq!(dashboard.search_query.as_deref(), Some("alpha")); assert_eq!(dashboard.search_query.as_deref(), Some("alpha.*"));
assert_eq!(dashboard.search_matches, vec![0, 2]); assert_eq!(dashboard.search_matches, vec![0, 2]);
assert_eq!(dashboard.selected_search_match, 0); assert_eq!(dashboard.selected_search_match, 0);
assert_eq!( assert_eq!(
dashboard.operator_note.as_deref(), dashboard.operator_note.as_deref(),
Some("search /alpha matched 2 line(s) | n/N navigate matches") Some("search /alpha.* matched 2 line(s) | n/N navigate matches")
); );
} }
@@ -4123,7 +4133,7 @@ diff --git a/src/next.rs b/src/next.rs
vec![ vec![
OutputLine { OutputLine {
stream: OutputStream::Stdout, stream: OutputStream::Stdout,
text: "alpha".to_string(), text: "alpha-1".to_string(),
}, },
OutputLine { OutputLine {
stream: OutputStream::Stdout, stream: OutputStream::Stdout,
@@ -4131,11 +4141,11 @@ diff --git a/src/next.rs b/src/next.rs
}, },
OutputLine { OutputLine {
stream: OutputStream::Stdout, stream: OutputStream::Stdout,
text: "alpha tail".to_string(), text: "alpha-2".to_string(),
}, },
], ],
); );
dashboard.search_query = Some("alpha".to_string()); dashboard.search_query = Some(r"alpha-\d".to_string());
dashboard.last_output_height = 1; dashboard.last_output_height = 1;
dashboard.recompute_search_matches(); dashboard.recompute_search_matches();
@@ -4144,7 +4154,7 @@ diff --git a/src/next.rs b/src/next.rs
assert_eq!(dashboard.output_scroll_offset, 2); assert_eq!(dashboard.output_scroll_offset, 2);
assert_eq!( assert_eq!(
dashboard.operator_note.as_deref(), dashboard.operator_note.as_deref(),
Some("search /alpha match 2/2") Some(r"search /alpha-\d match 2/2")
); );
dashboard.next_search_match(); dashboard.next_search_match();
@@ -4152,6 +4162,36 @@ diff --git a/src/next.rs b/src/next.rs
assert_eq!(dashboard.output_scroll_offset, 0); assert_eq!(dashboard.output_scroll_offset, 0);
} }
#[test]
fn submit_search_rejects_invalid_regex_and_keeps_input() {
let mut dashboard = test_dashboard(
vec![sample_session(
"focus-12345678",
"planner",
SessionState::Running,
None,
1,
1,
)],
0,
);
dashboard.begin_search();
for ch in "(".chars() {
dashboard.push_search_char(ch);
}
dashboard.submit_search();
assert_eq!(dashboard.search_input.as_deref(), Some("("));
assert!(dashboard.search_query.is_none());
assert!(dashboard.search_matches.is_empty());
assert!(dashboard
.operator_note
.as_deref()
.unwrap_or_default()
.starts_with("invalid regex /(:"));
}
#[test] #[test]
fn clear_search_resets_active_query_and_matches() { fn clear_search_resets_active_query_and_matches() {
let mut dashboard = test_dashboard(Vec::new(), 0); let mut dashboard = test_dashboard(Vec::new(), 0);