diff --git a/src/app.rs b/src/app.rs index 67d8fdb..bdcce14 100644 --- a/src/app.rs +++ b/src/app.rs @@ -7,6 +7,22 @@ use crate::parser::{load_groups, CommandFile, Group, Section}; pub enum Screen { Main, Custom, + Search, +} + +#[derive(Debug, Clone)] +pub struct SearchResult { + pub group: String, + pub file: String, + pub section: String, + pub command: String, +} + +#[derive(Debug, Clone)] +pub struct SearchState { + pub query: String, + pub results: Vec, + pub cursor: usize, } #[derive(Debug, Clone)] @@ -129,6 +145,7 @@ pub struct App { pub focus: Focus, pub screen: Screen, pub custom_state: Option, + pub search_state: Option, pub pending_command: Option, pub message: Option, } @@ -147,6 +164,7 @@ impl App { focus: Focus::Groups, screen: Screen::Main, custom_state: None, + search_state: None, pending_command: None, message: None, } @@ -171,6 +189,37 @@ impl App { self.custom_state = None; } + pub fn enter_search(&mut self) { + let results = build_search_index(&self.groups); + self.search_state = Some(SearchState { + query: String::new(), + results, + cursor: 0, + }); + self.screen = Screen::Search; + } + + pub fn exit_search(&mut self) { + self.screen = Screen::Main; + self.search_state = None; + } + + pub fn update_search(&mut self) { + if let Some(state) = self.search_state.as_mut() { + let query = state.query.to_lowercase(); + state.results = build_search_index(&self.groups) + .into_iter() + .filter(|r| { + r.group.to_lowercase().contains(&query) + || r.file.to_lowercase().contains(&query) + || r.section.to_lowercase().contains(&query) + || r.command.to_lowercase().contains(&query) + }) + .collect(); + state.cursor = state.cursor.min(state.results.len().saturating_sub(1)); + } + } + pub fn current_group(&self) -> Option<&Group> { self.groups.get(self.group_index) } @@ -263,3 +312,25 @@ impl App { }; } } + +fn build_search_index(groups: &[Group]) -> Vec { + let mut results = Vec::new(); + for group in groups { + for file in &group.files { + for section in &file.sections { + if section.is_run_all() { + continue; + } + for command in §ion.commands { + results.push(SearchResult { + group: group.name.clone(), + file: file.name.clone(), + section: section.title.clone(), + command: command.clone(), + }); + } + } + } + } + results +} diff --git a/src/events.rs b/src/events.rs index d83670d..d8b2bae 100644 --- a/src/events.rs +++ b/src/events.rs @@ -34,6 +34,7 @@ pub fn handle_event(app: &mut App, event: AppEvent) -> bool { match app.screen { Screen::Main => handle_main_event(app, key), Screen::Custom => handle_custom_event(app, key), + Screen::Search => handle_search_event(app, key), } } } @@ -55,6 +56,9 @@ fn handle_main_event(app: &mut App, key: event::KeyEvent) -> bool { KeyCode::Char('c') => { app.enter_custom(); } + KeyCode::Char('/') => { + app.enter_search(); + } KeyCode::Char('o') => { if let (Some(section), Some(file), Some(group)) = (app.current_section(), app.current_file(), app.current_group()) @@ -167,3 +171,47 @@ fn handle_custom_event(app: &mut App, key: event::KeyEvent) -> bool { } true } + +fn handle_search_event(app: &mut App, key: event::KeyEvent) -> bool { + let Some(state) = app.search_state.as_mut() else { + app.exit_search(); + return true; + }; + + match key.code { + KeyCode::Esc | KeyCode::Char('q') => { + app.exit_search(); + } + KeyCode::Down => { + if !state.results.is_empty() { + state.cursor = (state.cursor + 1) % state.results.len(); + } + } + KeyCode::Up => { + if !state.results.is_empty() { + state.cursor = state.cursor.checked_sub(1).unwrap_or(state.results.len() - 1); + } + } + KeyCode::Backspace => { + state.query.pop(); + app.update_search(); + } + KeyCode::Enter => { + if let Some(result) = state.results.get(state.cursor) { + app.pending_command = Some(PendingCommand { + group: result.group.clone(), + file: result.file.clone(), + section: result.section.clone(), + commands: vec![result.command.clone()], + }); + return false; + } + } + KeyCode::Char(c) => { + state.query.push(c); + app.update_search(); + } + _ => {} + } + true +} diff --git a/src/ui.rs b/src/ui.rs index 1ce5fae..0031806 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -12,6 +12,7 @@ pub fn draw(f: &mut Frame, app: &App) { match app.screen { Screen::Main => draw_main(f, app), Screen::Custom => draw_custom(f, app), + Screen::Search => draw_search(f, app), } } @@ -118,6 +119,70 @@ fn draw_custom(f: &mut Frame, app: &App) { f.render_widget(paragraph, chunks[2]); } +fn draw_search(f: &mut Frame, app: &App) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(1) + .constraints([ + Constraint::Length(3), + Constraint::Min(5), + Constraint::Length(1), + ]) + .split(f.area()); + + let Some(state) = app.search_state.as_ref() else { + return; + }; + + let input = Paragraph::new(state.query.as_str()) + .block( + Block::default() + .title(" Search ") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Yellow)), + ) + .wrap(Wrap { trim: true }); + f.render_widget(input, chunks[0]); + + let items: Vec = state + .results + .iter() + .enumerate() + .map(|(i, result)| { + let style = if i == state.cursor { + Style::default() + .bg(Color::Blue) + .fg(Color::Black) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + }; + ListItem::new(format!( + "[{}] {} — {}", + result.group, result.file, result.section + )) + .style(style) + }) + .collect(); + + let list = List::new(items).block( + Block::default() + .title(format!(" Results ({}) ", state.results.len())) + .borders(Borders::ALL), + ); + f.render_widget(list, chunks[1]); + + let preview_text = state + .results + .get(state.cursor) + .map(|r| Text::from(r.command.as_str())) + .unwrap_or_else(|| Text::from("No result selected.")); + let preview = Paragraph::new(preview_text) + .block(Block::default().title(" Preview ").borders(Borders::ALL)) + .wrap(Wrap { trim: true }); + f.render_widget(preview, chunks[2]); +} + fn draw_groups(f: &mut Frame, app: &App, area: ratatui::layout::Rect) { let items: Vec = app .groups @@ -256,7 +321,7 @@ fn draw_preview(f: &mut Frame, app: &App, area: ratatui::layout::Rect) { } fn draw_status(f: &mut Frame, app: &App, area: ratatui::layout::Rect) { - let help = "↑/↓: move | →/←: panel | Enter: run | o: new window | c: custom | e: edit | r: reload | q: quit"; + let help = "↑/↓: move | →/←: panel | Enter: run | /: search | o: new window | c: custom | e: edit | r: reload | q: quit"; let text = match &app.message { Some(msg) => Text::from(msg.as_str()), None => Text::from(help),