use crossterm::event::KeyEvent;
use crate::git::{DiffContent, FileDiff};
use super::{App, RowKind, ScrollLayout, TextInputKeyEffect, handle_text_input_edit};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MatchLocation {
pub row: usize,
pub byte_start: usize,
pub byte_end: usize,
}
#[derive(Debug, Clone)]
pub struct SearchState {
#[allow(dead_code)]
pub query: String,
pub matches: Vec<MatchLocation>,
pub current: usize,
}
#[derive(Debug, Clone, Default)]
pub struct SearchInputState {
pub query: String,
pub cursor_pos: usize,
}
fn find_ascii_case_insensitive(haystack: &str, needle: &str, start: usize) -> Option<usize> {
let haystack = haystack.as_bytes();
let needle = needle.as_bytes();
if needle.is_empty() || start > haystack.len() || needle.len() > haystack.len() - start {
return None;
}
let last_start = haystack.len() - needle.len();
(start..=last_start).find(|&idx| haystack[idx..idx + needle.len()].eq_ignore_ascii_case(needle))
}
pub fn find_matches(layout: &ScrollLayout, files: &[FileDiff], query: &str) -> Vec<MatchLocation> {
if query.is_empty() {
return Vec::new();
}
let case_sensitive = query.chars().any(|c| c.is_uppercase());
let needle: String = if case_sensitive {
query.to_string()
} else {
query.to_lowercase()
};
let mut out = Vec::new();
for (row_idx, row) in layout.rows.iter().enumerate() {
let RowKind::DiffLine {
file_idx,
hunk_idx,
line_idx,
} = row
else {
continue;
};
let Some(file) = files.get(*file_idx) else {
continue;
};
let DiffContent::Text(hunks) = &file.content else {
continue;
};
let Some(hunk) = hunks.get(*hunk_idx) else {
continue;
};
let Some(line) = hunk.lines.get(*line_idx) else {
continue;
};
let mut start = 0;
if !case_sensitive && needle.is_ascii() && line.content.is_ascii() {
while let Some(byte_start) = find_ascii_case_insensitive(&line.content, &needle, start)
{
let byte_end = byte_start + needle.len();
out.push(MatchLocation {
row: row_idx,
byte_start,
byte_end,
});
start = byte_end;
}
continue;
}
let haystack = line.content.as_str();
let search_needle = if case_sensitive {
query
} else {
needle.as_str()
};
while let Some(idx) = haystack[start..].find(search_needle) {
let byte_start = start + idx;
let byte_end = byte_start + search_needle.len();
out.push(MatchLocation {
row: row_idx,
byte_start,
byte_end,
});
if byte_end == start {
break;
}
start = byte_end;
}
}
out
}
impl App {
pub fn open_search_input(&mut self) {
self.search_input = Some(SearchInputState::default());
}
pub fn close_search_input(&mut self) {
self.search_input = None;
}
pub fn commit_search_input(&mut self) {
let Some(input) = self.search_input.take() else {
return;
};
let query = input.query;
if query.is_empty() {
return;
}
let matches = find_matches(&self.layout, &self.files, &query);
let cursor_row = self.scroll;
let current = matches.iter().position(|m| m.row > cursor_row).unwrap_or(0);
let target_row = matches.get(current).map(|m| m.row);
self.search = Some(SearchState {
query,
matches,
current,
});
if let Some(row) = target_row {
self.follow_mode = false;
self.scroll_to(row);
}
}
pub(crate) fn handle_search_input_key(&mut self, key: KeyEvent) {
let Some(s) = self.search_input.as_mut() else {
return;
};
match handle_text_input_edit(key, &mut s.query, &mut s.cursor_pos) {
TextInputKeyEffect::Continue => {}
TextInputKeyEffect::Commit => self.commit_search_input(),
TextInputKeyEffect::Cancel => self.close_search_input(),
}
}
pub(crate) fn search_jump_by(&mut self, delta: isize) {
let Some(state) = self.search.as_mut() else {
return;
};
if state.matches.is_empty() {
return;
}
let len = state.matches.len() as isize;
state.current = (state.current as isize + delta).rem_euclid(len) as usize;
let row = state.matches[state.current].row;
self.follow_mode = false;
self.scroll_to(row);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ascii_case_insensitive_find_reports_original_byte_offsets() {
let haystack = "Hello WORLD world";
assert_eq!(find_ascii_case_insensitive(haystack, "world", 0), Some(6));
assert_eq!(find_ascii_case_insensitive(haystack, "world", 7), Some(12));
assert_eq!(find_ascii_case_insensitive(haystack, "world", 13), None);
}
}