use crate::action::Action;
use crate::components::command_panel::FileCompletionCache;
use crate::model::panel_state::{SourcePanelMode, SourcePanelState};
pub struct SourceSearch;
impl SourceSearch {
pub fn enter_search_mode(state: &mut SourcePanelState) -> Vec<Action> {
state.mode = SourcePanelMode::TextSearch;
state.search_query.clear();
state.search_matches.clear();
state.current_match = None;
Vec::new()
}
pub fn exit_search_mode(state: &mut SourcePanelState) -> Vec<Action> {
state.mode = SourcePanelMode::Normal;
state.search_query.clear();
state.search_matches.clear();
state.current_match = None;
Vec::new()
}
pub fn push_search_char(state: &mut SourcePanelState, ch: char) -> Vec<Action> {
if state.mode == SourcePanelMode::TextSearch {
state.search_query.push(ch);
Self::update_search_matches(state);
if !state.search_matches.is_empty() {
state.current_match = Some(0);
Self::jump_to_match(state, 0);
}
}
Vec::new()
}
pub fn backspace_search(state: &mut SourcePanelState) -> Vec<Action> {
if state.mode == SourcePanelMode::TextSearch {
state.search_query.pop();
Self::update_search_matches(state);
if !state.search_matches.is_empty() {
state.current_match = Some(0);
Self::jump_to_match(state, 0);
} else {
state.current_match = None;
}
}
Vec::new()
}
pub fn confirm_search(state: &mut SourcePanelState) -> Vec<Action> {
if state.mode == SourcePanelMode::TextSearch {
state.mode = SourcePanelMode::Normal;
if !state.search_matches.is_empty() && state.current_match.is_none() {
state.current_match = Some(0);
Self::jump_to_match(state, 0);
}
}
Vec::new()
}
pub fn next_match(state: &mut SourcePanelState) -> Vec<Action> {
if !state.search_matches.is_empty() {
let current = state.current_match.unwrap_or(0);
let next = (current + 1) % state.search_matches.len();
state.current_match = Some(next);
Self::jump_to_match(state, next);
if current == state.search_matches.len() - 1 && next == 0 {
tracing::info!("Search wrapped to top");
}
}
Vec::new()
}
pub fn prev_match(state: &mut SourcePanelState) -> Vec<Action> {
if !state.search_matches.is_empty() {
let current = state.current_match.unwrap_or(0);
let prev = if current == 0 {
state.search_matches.len() - 1
} else {
current - 1
};
state.current_match = Some(prev);
Self::jump_to_match(state, prev);
if current == 0 && prev == state.search_matches.len() - 1 {
tracing::info!("Search wrapped to bottom");
}
}
Vec::new()
}
pub fn enter_file_search_mode(state: &mut SourcePanelState) -> Vec<Action> {
state.mode = SourcePanelMode::FileSearch;
state.file_search_query.clear();
state.file_search_cursor_pos = 0;
state.file_search_filtered_indices.clear();
state.file_search_selected = 0;
state.file_search_scroll = 0;
state.file_search_message = Some("Loading files...".to_string());
Vec::new()
}
pub fn exit_file_search_mode(state: &mut SourcePanelState) -> Vec<Action> {
state.mode = SourcePanelMode::Normal;
state.file_search_query.clear();
state.file_search_cursor_pos = 0;
state.file_search_filtered_indices.clear();
state.file_search_selected = 0;
state.file_search_scroll = 0;
state.file_search_message = None;
Vec::new()
}
pub fn push_file_search_char(
state: &mut SourcePanelState,
cache: &FileCompletionCache,
ch: char,
) -> Vec<Action> {
if state.mode == SourcePanelMode::FileSearch {
let mut chars: Vec<char> = state.file_search_query.chars().collect();
chars.insert(state.file_search_cursor_pos, ch);
state.file_search_query = chars.into_iter().collect();
state.file_search_cursor_pos += 1;
Self::update_file_search_results(state, cache);
}
Vec::new()
}
pub fn backspace_file_search(
state: &mut SourcePanelState,
cache: &FileCompletionCache,
) -> Vec<Action> {
if state.mode == SourcePanelMode::FileSearch && state.file_search_cursor_pos > 0 {
let mut chars: Vec<char> = state.file_search_query.chars().collect();
chars.remove(state.file_search_cursor_pos - 1);
state.file_search_query = chars.into_iter().collect();
state.file_search_cursor_pos -= 1;
Self::update_file_search_results(state, cache);
}
Vec::new()
}
pub fn clear_file_search_query(
state: &mut SourcePanelState,
cache: &FileCompletionCache,
) -> Vec<Action> {
if state.mode == SourcePanelMode::FileSearch {
state.file_search_query.clear();
state.file_search_cursor_pos = 0;
Self::update_file_search_results(state, cache);
}
Vec::new()
}
pub fn delete_word_file_search(
state: &mut SourcePanelState,
cache: &FileCompletionCache,
) -> Vec<Action> {
if state.mode == SourcePanelMode::FileSearch && state.file_search_cursor_pos > 0 {
let chars: Vec<char> = state.file_search_query.chars().collect();
let mut start_pos = state.file_search_cursor_pos;
let is_separator = |c: char| {
c.is_whitespace() || c == '/' || c == '\\' || c == '.' || c == '-' || c == '_'
};
while start_pos > 0 && is_separator(chars[start_pos - 1]) {
start_pos -= 1;
}
while start_pos > 0 && !is_separator(chars[start_pos - 1]) {
start_pos -= 1;
}
let mut new_chars = chars[..start_pos].to_vec();
new_chars.extend_from_slice(&chars[state.file_search_cursor_pos..]);
state.file_search_query = new_chars.into_iter().collect();
state.file_search_cursor_pos = start_pos;
Self::update_file_search_results(state, cache);
}
Vec::new()
}
pub fn move_cursor_to_start(state: &mut SourcePanelState) -> Vec<Action> {
if state.mode == SourcePanelMode::FileSearch {
state.file_search_cursor_pos = 0;
}
Vec::new()
}
pub fn move_cursor_to_end(state: &mut SourcePanelState) -> Vec<Action> {
if state.mode == SourcePanelMode::FileSearch {
state.file_search_cursor_pos = state.file_search_query.chars().count();
}
Vec::new()
}
pub fn move_cursor_left(state: &mut SourcePanelState) -> Vec<Action> {
if state.mode == SourcePanelMode::FileSearch && state.file_search_cursor_pos > 0 {
state.file_search_cursor_pos -= 1;
}
Vec::new()
}
pub fn move_cursor_right(state: &mut SourcePanelState) -> Vec<Action> {
if state.mode == SourcePanelMode::FileSearch {
let max_pos = state.file_search_query.chars().count();
if state.file_search_cursor_pos < max_pos {
state.file_search_cursor_pos += 1;
}
}
Vec::new()
}
pub fn move_file_search_up(state: &mut SourcePanelState) -> Vec<Action> {
if state.mode == SourcePanelMode::FileSearch
&& !state.file_search_filtered_indices.is_empty()
{
if state.file_search_selected > 0 {
state.file_search_selected -= 1;
} else {
state.file_search_selected = state.file_search_filtered_indices.len() - 1;
}
Self::ensure_file_search_visible(state);
}
Vec::new()
}
pub fn move_file_search_down(state: &mut SourcePanelState) -> Vec<Action> {
if state.mode == SourcePanelMode::FileSearch
&& !state.file_search_filtered_indices.is_empty()
{
state.file_search_selected =
(state.file_search_selected + 1) % state.file_search_filtered_indices.len();
Self::ensure_file_search_visible(state);
}
Vec::new()
}
pub fn confirm_file_search(
state: &mut SourcePanelState,
cache: &FileCompletionCache,
) -> Option<String> {
if state.mode == SourcePanelMode::FileSearch
&& !state.file_search_filtered_indices.is_empty()
{
let real_idx = state.file_search_filtered_indices[state.file_search_selected];
let selected_file = cache.get_all_files().get(real_idx).cloned();
Self::exit_file_search_mode(state);
selected_file
} else {
None
}
}
pub fn set_file_search_files(
state: &mut SourcePanelState,
cache: &mut FileCompletionCache,
files: Vec<String>,
) -> Vec<Action> {
cache.set_all_files(files);
state.file_search_message = None;
Self::update_file_search_results(state, cache);
Vec::new()
}
pub fn set_file_search_error(state: &mut SourcePanelState, error: String) -> Vec<Action> {
state.file_search_message = Some(format!("✗ {error}"));
state.file_search_filtered_indices.clear();
Vec::new()
}
fn update_search_matches(state: &mut SourcePanelState) {
let old_cursor_line = state.cursor_line;
let old_cursor_col = state.cursor_col;
state.search_matches.clear();
state.current_match = None;
if state.search_query.is_empty() {
return;
}
let query = state.search_query.to_lowercase();
for (line_idx, line) in state.content.iter().enumerate() {
let line_lower = line.to_lowercase();
let mut start = 0;
while let Some(pos) = line_lower[start..].find(&query) {
let match_start = start + pos;
let match_end = match_start + query.len();
state
.search_matches
.push((line_idx, match_start, match_end));
start = match_start + 1;
}
}
if !state.search_matches.is_empty() {
let mut best_match = 0;
for (idx, (line_idx, col_start, _)) in state.search_matches.iter().enumerate() {
if *line_idx > old_cursor_line
|| (*line_idx == old_cursor_line && *col_start >= old_cursor_col)
{
best_match = idx;
break;
}
}
state.current_match = Some(best_match);
}
}
fn jump_to_match(state: &mut SourcePanelState, match_idx: usize) {
if let Some((line_idx, col_start, _)) = state.search_matches.get(match_idx) {
state.cursor_line = *line_idx;
state.cursor_col = *col_start;
let visible_lines = 30; if state.cursor_line < state.scroll_offset {
state.scroll_offset = state.cursor_line;
} else if state.cursor_line >= state.scroll_offset + visible_lines {
state.scroll_offset = state
.cursor_line
.saturating_sub(visible_lines.saturating_sub(1));
}
if let Some(current_line) = state.content.get(state.cursor_line) {
let line_number_width = 5; let border_width = 2; let available_width = (state
.area_width
.saturating_sub(line_number_width + border_width))
as usize;
if current_line.len() <= available_width {
state.horizontal_scroll_offset = 0;
} else {
let scrolloff = available_width / 3;
let ideal_scroll = state.cursor_col.saturating_sub(scrolloff);
let max_scroll = current_line.len().saturating_sub(available_width);
let near_end = state.cursor_col >= max_scroll.saturating_add(scrolloff);
if near_end {
state.horizontal_scroll_offset = max_scroll;
} else {
state.horizontal_scroll_offset = ideal_scroll.min(max_scroll);
}
}
}
}
}
fn update_file_search_results(state: &mut SourcePanelState, cache: &FileCompletionCache) {
state.file_search_filtered_indices.clear();
state.file_search_selected = 0;
state.file_search_scroll = 0;
let all_files = cache.get_all_files();
if state.file_search_query.is_empty() {
state.file_search_filtered_indices = (0..all_files.len()).collect();
} else {
let query = state.file_search_query.to_lowercase();
for (idx, file) in all_files.iter().enumerate() {
if file.to_lowercase().contains(&query) {
state.file_search_filtered_indices.push(idx);
}
}
}
}
fn ensure_file_search_visible(state: &mut SourcePanelState) {
let visible_count = 10;
if state.file_search_selected < state.file_search_scroll {
state.file_search_scroll = state.file_search_selected;
} else if state.file_search_selected >= state.file_search_scroll + visible_count {
state.file_search_scroll = state.file_search_selected.saturating_sub(visible_count - 1);
}
}
}