use crate::pane::PaneId;
use crate::session::Session;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SearchLineResult {
pub row: usize,
pub col: usize,
pub matched_text: String,
pub line_content: String,
}
#[derive(Debug, Clone)]
pub struct GlobalSearchResult {
pub pane_id: PaneId,
pub pane_title: String,
pub tab_index: usize,
pub row: usize,
pub col: usize,
pub matched_text: String,
pub line_content: String,
}
pub fn search_text(text: &str, query: &str, regex: bool) -> Vec<(usize, usize, String)> {
if query.is_empty() || text.is_empty() {
return Vec::new();
}
let lines: Vec<String> = text.lines().map(String::from).collect();
search_lines(&lines, query, regex)
.into_iter()
.map(|r| (r.row, r.col, r.matched_text))
.collect()
}
pub fn search_lines(lines: &[String], query: &str, regex: bool) -> Vec<SearchLineResult> {
if query.is_empty() || lines.is_empty() {
return Vec::new();
}
if regex {
search_lines_regex(lines, query)
} else {
search_lines_plain(lines, query)
}
}
fn search_lines_plain(lines: &[String], query: &str) -> Vec<SearchLineResult> {
let mut results = Vec::new();
for (row_idx, line) in lines.iter().enumerate() {
let mut start = 0;
while let Some(byte_pos) = line[start..].find(query) {
let abs_byte = start + byte_pos;
let col = line[..abs_byte].chars().count();
results.push(SearchLineResult {
row: row_idx,
col,
matched_text: query.to_string(),
line_content: line.clone(),
});
start = abs_byte + query.len().max(1);
}
}
results
}
fn search_lines_regex(lines: &[String], pattern: &str) -> Vec<SearchLineResult> {
let re = match regex::Regex::new(pattern) {
Ok(re) => re,
Err(_) => return Vec::new(),
};
let mut results = Vec::new();
for (row_idx, line) in lines.iter().enumerate() {
for m in re.find_iter(line) {
let col = line[..m.start()].chars().count();
results.push(SearchLineResult {
row: row_idx,
col,
matched_text: m.as_str().to_string(),
line_content: line.clone(),
});
}
}
results
}
pub fn search_session(session: &Session, query: &str, regex: bool) -> Vec<GlobalSearchResult> {
let mut results = Vec::new();
if query.is_empty() {
return results;
}
for tab_index in 0..session.tab_count() {
let tab = match session.tab(tab_index) {
Some(t) => t,
None => continue,
};
for pane_id in tab.pane_ids() {
if let Some(pane) = tab.pane(pane_id) {
let lines: Vec<String> = pane.scrollback().to_vec();
let matches = search_lines(&lines, query, regex);
for m in matches {
results.push(GlobalSearchResult {
pane_id,
pane_title: pane.title().to_string(),
tab_index,
row: m.row,
col: m.col,
matched_text: m.matched_text,
line_content: m.line_content,
});
}
}
}
for fp_id in tab.floating_pane_ids() {
if let Some(fp) = tab.floating_pane(fp_id) {
let pane = &fp.pane;
let lines: Vec<String> = pane.scrollback().to_vec();
let matches = search_lines(&lines, query, regex);
for m in matches {
results.push(GlobalSearchResult {
pane_id: fp_id,
pane_title: pane.title().to_string(),
tab_index,
row: m.row,
col: m.col,
matched_text: m.matched_text,
line_content: m.line_content,
});
}
}
}
}
results
}
#[cfg(test)]
mod tests {
use super::*;
use crate::layout::SplitDirection;
#[test]
fn search_text_plain_match() {
let results = search_text("hello world", "hello", false);
assert_eq!(results.len(), 1);
assert_eq!(results[0], (0, 0, "hello".to_string()));
}
#[test]
fn search_text_regex_match() {
let results = search_text("error 404 not found", r"\d+", true);
assert_eq!(results.len(), 1);
assert_eq!(results[0], (0, 6, "404".to_string()));
}
#[test]
fn search_text_no_match() {
let results = search_text("hello world", "foobar", false);
assert!(results.is_empty());
}
#[test]
fn search_text_multiple_matches() {
let results = search_text("abcabc", "abc", false);
assert_eq!(results.len(), 2);
assert_eq!(results[0], (0, 0, "abc".to_string()));
assert_eq!(results[1], (0, 3, "abc".to_string()));
}
#[test]
fn search_text_case_sensitive() {
let results = search_text("Hello hello HELLO", "hello", false);
assert_eq!(results.len(), 1);
assert_eq!(results[0], (0, 6, "hello".to_string()));
}
#[test]
fn search_lines_multiple_rows() {
let lines = vec![
"first line with target".to_string(),
"no match here".to_string(),
"another target found".to_string(),
];
let results = search_lines(&lines, "target", false);
assert_eq!(results.len(), 2);
assert_eq!(results[0].row, 0);
assert_eq!(results[0].col, 16);
assert_eq!(results[0].matched_text, "target");
assert_eq!(results[0].line_content, "first line with target");
assert_eq!(results[1].row, 2);
assert_eq!(results[1].col, 8);
}
#[test]
fn search_lines_empty_input() {
let lines: Vec<String> = vec![];
let results = search_lines(&lines, "query", false);
assert!(results.is_empty());
}
#[test]
fn search_session_across_panes() {
let mut session = Session::new("test", 80, 25);
{
let tab = session.active_tab_mut();
let pane_id = tab.active_pane_id().unwrap();
let pane = tab.pane_mut(pane_id).unwrap();
pane.push_scrollback("hello from pane 0");
pane.push_scrollback("world");
}
{
let tab = session.active_tab_mut();
tab.split_pane(SplitDirection::Vertical);
let pane_id = tab.active_pane_id().unwrap();
let pane = tab.pane_mut(pane_id).unwrap();
pane.push_scrollback("hello from pane 1");
}
let results = search_session(&session, "hello", false);
assert_eq!(results.len(), 2);
assert_eq!(results[0].tab_index, 0);
assert_eq!(results[1].tab_index, 0);
assert!(results.iter().all(|r| r.matched_text == "hello"));
assert_ne!(results[0].pane_id, results[1].pane_id);
}
#[test]
fn search_session_empty_query() {
let session = Session::new("test", 80, 25);
let results = search_session(&session, "", false);
assert!(results.is_empty());
}
#[test]
fn search_session_no_match() {
let mut session = Session::new("test", 80, 25);
{
let tab = session.active_tab_mut();
let pane_id = tab.active_pane_id().unwrap();
let pane = tab.pane_mut(pane_id).unwrap();
pane.push_scrollback("some content");
}
let results = search_session(&session, "zzz_not_found", false);
assert!(results.is_empty());
}
#[test]
fn search_session_includes_floating_panes() {
let mut session = Session::new("test", 80, 25);
{
let tab = session.active_tab_mut();
let fp_id = tab.new_floating_pane();
tab.floating_pane_mut(fp_id)
.unwrap()
.pane
.push_scrollback("floating match");
}
let results = search_session(&session, "floating", false);
assert_eq!(results.len(), 1);
assert_eq!(results[0].matched_text, "floating");
}
#[test]
fn search_session_multiple_tabs() {
let mut session = Session::new("test", 80, 25);
{
let tab = session.active_tab_mut();
let pid = tab.active_pane_id().unwrap();
tab.pane_mut(pid)
.unwrap()
.push_scrollback("needle in tab 0");
}
session.new_tab("Tab 2");
{
let tab = session.active_tab_mut();
let pid = tab.active_pane_id().unwrap();
tab.pane_mut(pid)
.unwrap()
.push_scrollback("needle in tab 1");
}
let results = search_session(&session, "needle", false);
assert_eq!(results.len(), 2);
assert_eq!(results[0].tab_index, 0);
assert_eq!(results[1].tab_index, 1);
}
#[test]
fn search_session_regex() {
let mut session = Session::new("test", 80, 25);
{
let tab = session.active_tab_mut();
let pid = tab.active_pane_id().unwrap();
tab.pane_mut(pid).unwrap().push_scrollback("error code 42");
tab.pane_mut(pid).unwrap().push_scrollback("no numbers");
}
let results = search_session(&session, r"\d+", true);
assert_eq!(results.len(), 1);
assert_eq!(results[0].matched_text, "42");
}
}