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 {
|
pub enum Screen {
|
||||||
Main,
|
Main,
|
||||||
Custom,
|
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)]
|
#[derive(Debug, Clone)]
|
||||||
|
|
@ -129,6 +145,7 @@ pub struct App {
|
||||||
pub focus: Focus,
|
pub focus: Focus,
|
||||||
pub screen: Screen,
|
pub screen: Screen,
|
||||||
pub custom_state: Option<CustomState>,
|
pub custom_state: Option<CustomState>,
|
||||||
|
pub search_state: Option<SearchState>,
|
||||||
pub pending_command: Option<PendingCommand>,
|
pub pending_command: Option<PendingCommand>,
|
||||||
pub message: Option<String>,
|
pub message: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
@ -147,6 +164,7 @@ impl App {
|
||||||
focus: Focus::Groups,
|
focus: Focus::Groups,
|
||||||
screen: Screen::Main,
|
screen: Screen::Main,
|
||||||
custom_state: None,
|
custom_state: None,
|
||||||
|
search_state: None,
|
||||||
pending_command: None,
|
pending_command: None,
|
||||||
message: None,
|
message: None,
|
||||||
}
|
}
|
||||||
|
|
@ -171,6 +189,37 @@ impl App {
|
||||||
self.custom_state = None;
|
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> {
|
pub fn current_group(&self) -> Option<&Group> {
|
||||||
self.groups.get(self.group_index)
|
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 {
|
match app.screen {
|
||||||
Screen::Main => handle_main_event(app, key),
|
Screen::Main => handle_main_event(app, key),
|
||||||
Screen::Custom => handle_custom_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') => {
|
KeyCode::Char('c') => {
|
||||||
app.enter_custom();
|
app.enter_custom();
|
||||||
}
|
}
|
||||||
|
KeyCode::Char('/') => {
|
||||||
|
app.enter_search();
|
||||||
|
}
|
||||||
KeyCode::Char('o') => {
|
KeyCode::Char('o') => {
|
||||||
if let (Some(section), Some(file), Some(group)) =
|
if let (Some(section), Some(file), Some(group)) =
|
||||||
(app.current_section(), app.current_file(), app.current_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
|
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 {
|
match app.screen {
|
||||||
Screen::Main => draw_main(f, app),
|
Screen::Main => draw_main(f, app),
|
||||||
Screen::Custom => draw_custom(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]);
|
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) {
|
fn draw_groups(f: &mut Frame, app: &App, area: ratatui::layout::Rect) {
|
||||||
let items: Vec<ListItem> = app
|
let items: Vec<ListItem> = app
|
||||||
.groups
|
.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) {
|
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 {
|
let text = match &app.message {
|
||||||
Some(msg) => Text::from(msg.as_str()),
|
Some(msg) => Text::from(msg.as_str()),
|
||||||
None => Text::from(help),
|
None => Text::from(help),
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue