Add global search across all commands

This commit is contained in:
SW 2026-06-17 18:39:24 +02:00
parent e0a4290743
commit cbce822e8c
3 changed files with 185 additions and 1 deletions

View file

@ -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<SearchResult>,
pub cursor: usize,
}
#[derive(Debug, Clone)]
@ -129,6 +145,7 @@ pub struct App {
pub focus: Focus,
pub screen: Screen,
pub custom_state: Option<CustomState>,
pub search_state: Option<SearchState>,
pub pending_command: Option<PendingCommand>,
pub message: Option<String>,
}
@ -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<SearchResult> {
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 &section.commands {
results.push(SearchResult {
group: group.name.clone(),
file: file.name.clone(),
section: section.title.clone(),
command: command.clone(),
});
}
}
}
}
results
}

View file

@ -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
}

View file

@ -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<ListItem> = 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<ListItem> = 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),