Add global search across all commands
This commit is contained in:
parent
e0a4290743
commit
cbce822e8c
3 changed files with 185 additions and 1 deletions
71
src/app.rs
71
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<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 §ion.commands {
|
||||
results.push(SearchResult {
|
||||
group: group.name.clone(),
|
||||
file: file.name.clone(),
|
||||
section: section.title.clone(),
|
||||
command: command.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
results
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
67
src/ui.rs
67
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<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),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue