From 9db61f3b6a79f35f5f2e63532c92f1d6749f2e4c Mon Sep 17 00:00:00 2001 From: Alexander Tesfamichael Date: Mon, 29 Dec 2025 00:43:19 +0100 Subject: [PATCH] feat(tui): add filtering in all issues view Add the ability to filter issues in the TUI's "All" pane: - Press `/` to open filter dialog for text search by title - Use keys 1-4 while filtering to toggle status filters (todo/doing/done/skip) - Press Esc to clear active filters - Filter status shown in pane title with match count The filter is case-insensitive and updates the visible list in real-time. --- src/issue.rs | 2 +- src/tui/app.rs | 117 +++++++++++++++++++++++++++++++++++++++++++++-- src/tui/event.rs | 39 ++++++++++++++++ src/tui/ui.rs | 98 ++++++++++++++++++++++++++++++++++----- 4 files changed, 241 insertions(+), 15 deletions(-) diff --git a/src/issue.rs b/src/issue.rs index fa5b2d1..0846f2c 100644 --- a/src/issue.rs +++ b/src/issue.rs @@ -44,7 +44,7 @@ impl std::str::FromStr for Priority { } /// issue status. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum Status { Todo, diff --git a/src/tui/app.rs b/src/tui/app.rs index 55d860f..caf5760 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -1,6 +1,6 @@ //! TUI application state and logic. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use crate::config::Config; use crate::error::{BrdError, Result}; @@ -40,6 +40,8 @@ pub enum InputMode { EditPriority { issue_id: String, selected: usize }, /// editing issue status EditStatus { issue_id: String, selected: usize }, + /// filtering issues in the all pane + Filter(String), } /// TUI application state. @@ -50,6 +52,8 @@ pub struct App { pub ready_issues: Vec, /// all issues (sorted) pub all_issues: Vec, + /// filtered all issues (when filter is active) + pub filtered_all_issues: Vec, /// in-progress issues (sorted by age) pub in_progress_issues: Vec, /// recently completed issues (sorted by updated_at) @@ -82,6 +86,10 @@ pub struct App { pub config: Config, /// selected dependency index in detail pane pub detail_dep_selected: Option, + /// filter query for all issues pane + pub all_filter_query: String, + /// status filter for all issues (empty means show all) + pub all_status_filter: HashSet, } const RECENT_DONE_LIMIT: usize = 8; @@ -95,6 +103,7 @@ impl App { issues: HashMap::new(), ready_issues: Vec::new(), all_issues: Vec::new(), + filtered_all_issues: Vec::new(), in_progress_issues: Vec::new(), recent_done_issues: Vec::new(), ready_selected: 0, @@ -111,6 +120,8 @@ impl App { input_mode: InputMode::Normal, config, detail_dep_selected: None, + all_filter_query: String::new(), + all_status_filter: HashSet::new(), }; app.reload_issues(paths)?; Ok(app) @@ -183,12 +194,109 @@ impl App { } self.reset_dep_selection(); + self.apply_filter(); if show_message { self.message = Some("refreshed".to_string()); } Ok(()) } + /// apply the current filter to the all issues list. + pub fn apply_filter(&mut self) { + let query = self.all_filter_query.to_lowercase(); + self.filtered_all_issues = self + .all_issues + .iter() + .filter(|id| { + let Some(issue) = self.issues.get(*id) else { + return false; + }; + // check status filter (empty means show all) + if !self.all_status_filter.is_empty() + && !self.all_status_filter.contains(&issue.status()) + { + return false; + } + // check query filter + if !query.is_empty() && !issue.title().to_lowercase().contains(&query) { + return false; + } + true + }) + .cloned() + .collect(); + + // clamp selection to filtered list + // use filtered list length directly since we just populated it + let visible_len = if self.has_filter() { + self.filtered_all_issues.len() + } else { + self.all_issues.len() + }; + if self.all_selected >= visible_len && visible_len > 0 { + self.all_selected = visible_len - 1; + } + if self.all_offset >= visible_len { + self.all_offset = 0; + } + } + + /// returns true if a filter is currently active. + pub fn has_filter(&self) -> bool { + !self.all_filter_query.is_empty() || !self.all_status_filter.is_empty() + } + + /// get the visible all issues list (filtered or unfiltered). + pub fn visible_all_issues(&self) -> &Vec { + if self.has_filter() { + &self.filtered_all_issues + } else { + &self.all_issues + } + } + + /// toggle a status in the filter. + pub fn toggle_status_filter(&mut self, status: Status) { + if self.all_status_filter.contains(&status) { + self.all_status_filter.remove(&status); + } else { + self.all_status_filter.insert(status); + } + self.apply_filter(); + self.message = None; + } + + /// clear all filters. + pub fn clear_filter(&mut self) { + self.all_filter_query.clear(); + self.all_status_filter.clear(); + self.apply_filter(); + self.message = Some("filter cleared".to_string()); + } + + /// start filter input mode. + pub fn start_filter(&mut self) { + self.input_mode = InputMode::Filter(self.all_filter_query.clone()); + self.active_pane = ActivePane::All; + self.message = None; + } + + /// cancel filter input. + pub fn cancel_filter(&mut self) { + self.input_mode = InputMode::Normal; + self.message = None; + } + + /// confirm filter input. + pub fn confirm_filter(&mut self) { + if let InputMode::Filter(query) = &self.input_mode { + self.all_filter_query = query.clone(); + self.apply_filter(); + } + self.input_mode = InputMode::Normal; + self.message = None; + } + /// get the currently selected issue id. pub fn selected_issue_id(&self) -> Option<&str> { if self.view_mode == ViewMode::Live { @@ -202,7 +310,10 @@ impl App { .ready_issues .get(self.ready_selected) .map(|s| s.as_str()), - ActivePane::All => self.all_issues.get(self.all_selected).map(|s| s.as_str()), + ActivePane::All => self + .visible_all_issues() + .get(self.all_selected) + .map(|s| s.as_str()), } } @@ -259,7 +370,7 @@ impl App { } } ActivePane::All => { - if self.all_selected + 1 < self.all_issues.len() { + if self.all_selected + 1 < self.visible_all_issues().len() { self.all_selected += 1; } } diff --git a/src/tui/event.rs b/src/tui/event.rs index 9b1809b..d0d90b3 100644 --- a/src/tui/event.rs +++ b/src/tui/event.rs @@ -189,6 +189,37 @@ fn handle_key_event(app: &mut App, paths: &RepoPaths, key: KeyEvent) -> Result { + match key.code { + KeyCode::Esc => app.cancel_filter(), + KeyCode::Enter => app.confirm_filter(), + KeyCode::Backspace => { + let mut s = current.clone(); + s.pop(); + app.input_mode = InputMode::Filter(s); + } + // toggle status filters with 1-4 + KeyCode::Char('1') => { + app.toggle_status_filter(crate::issue::Status::Todo); + } + KeyCode::Char('2') => { + app.toggle_status_filter(crate::issue::Status::Doing); + } + KeyCode::Char('3') => { + app.toggle_status_filter(crate::issue::Status::Done); + } + KeyCode::Char('4') => { + app.toggle_status_filter(crate::issue::Status::Skip); + } + KeyCode::Char(c) => { + let mut s = current.clone(); + s.push(c); + app.input_mode = InputMode::Filter(s); + } + _ => {} + } + return Ok(false); + } InputMode::Normal => {} } @@ -225,6 +256,14 @@ fn handle_key_event(app: &mut App, paths: &RepoPaths, key: KeyEvent) -> Result app.toggle_live_view(), KeyCode::Enter => app.open_selected_dependency(), + // filter + KeyCode::Char('/') => app.start_filter(), + KeyCode::Esc => { + if app.has_filter() { + app.clear_filter(); + } + } + // help KeyCode::Char('?') => app.toggle_help(), diff --git a/src/tui/ui.rs b/src/tui/ui.rs index 8abd51d..381fc28 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -49,7 +49,7 @@ fn draw_header(f: &mut Frame, area: Rect, app: &App) { fn draw_footer(f: &mut Frame, area: Rect, app: &App) { let msg = app.message.as_deref().unwrap_or(""); - let help = "[a]dd [e]dit [s]tart [d]one [r]efresh [v]live [↑↓/jk]nav [Tab]switch [h/l]dep [enter]open dep [?]help [q]uit"; + let help = "[a]dd [e]dit [s]tart [d]one [r]efresh [/]filter [v]live [↑↓/jk]nav [Tab]switch [?]help [q]uit"; let text = if msg.is_empty() { help.to_string() } else { @@ -291,7 +291,35 @@ fn draw_all_list(f: &mut Frame, area: Rect, app: &mut App) { Style::default().fg(Color::DarkGray) }; - let title = format!(" All ({}) ", app.all_issues.len()); + // build title with filter info + let visible = app.visible_all_issues(); + let title = if app.has_filter() { + let mut filter_parts = Vec::new(); + if !app.all_filter_query.is_empty() { + filter_parts.push(format!("\"{}\"", app.all_filter_query)); + } + if !app.all_status_filter.is_empty() { + let statuses: Vec<&str> = app + .all_status_filter + .iter() + .map(|s| match s { + crate::issue::Status::Todo => "T", + crate::issue::Status::Doing => "D", + crate::issue::Status::Done => "✓", + crate::issue::Status::Skip => "⊘", + }) + .collect(); + filter_parts.push(statuses.join("")); + } + format!( + " All ({}/{}) [{}] ", + visible.len(), + app.all_issues.len(), + filter_parts.join(" ") + ) + } else { + format!(" All ({}) ", app.all_issues.len()) + }; let block = Block::default() .title(title) .borders(Borders::ALL) @@ -301,8 +329,7 @@ fn draw_all_list(f: &mut Frame, area: Rect, app: &mut App) { let title_width = area.width.saturating_sub(16) as usize; let view_height = block.inner(area).height as usize; - let items: Vec = app - .all_issues + let items: Vec = visible .iter() .enumerate() .map(|(i, id)| { @@ -348,17 +375,13 @@ fn draw_all_list(f: &mut Frame, area: Rect, app: &mut App) { }) .collect(); - let selected = if app.all_issues.is_empty() { + let visible_len = visible.len(); + let selected = if visible_len == 0 { None } else { Some(app.all_selected) }; - update_offset( - &mut app.all_offset, - selected, - app.all_issues.len(), - view_height, - ); + update_offset(&mut app.all_offset, selected, visible_len, view_height); let mut state = ListState::default() .with_selected(selected) .with_offset(app.all_offset); @@ -559,6 +582,14 @@ fn draw_help(f: &mut Frame, area: Rect) { Line::from(" r Refresh issues from disk"), Line::from(" v Toggle live view"), Line::from(""), + Line::from(Span::styled( + "Filter (All pane)", + Style::default().add_modifier(Modifier::BOLD), + )), + Line::from(" / Open filter dialog"), + Line::from(" 1-4 Toggle status (1=todo 2=doing 3=done 4=skip)"), + Line::from(" Esc Clear filter"), + Line::from(""), Line::from(Span::styled( "Other", Style::default().add_modifier(Modifier::BOLD), @@ -811,6 +842,51 @@ fn draw_input_dialog(f: &mut Frame, app: &App) { let list = List::new(items); f.render_widget(list, chunks[1]); } + InputMode::Filter(query) => { + let block = Block::default() + .title(" Filter (Enter to apply, Esc to cancel) ") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Yellow)); + + let inner = block.inner(area); + f.render_widget(block, area); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Min(0), + ]) + .split(inner); + + let hint = Paragraph::new("Type to filter by title. Use 1-4 to toggle status filters.") + .style(Style::default().fg(Color::DarkGray)); + f.render_widget(hint, chunks[0]); + + let input = + Paragraph::new(format!("/{}_", query)).style(Style::default().fg(Color::White)); + f.render_widget(input, chunks[1]); + + // show current status filter + let status_line = if app.all_status_filter.is_empty() { + "Status: all".to_string() + } else { + let statuses: Vec<&str> = [ + (crate::issue::Status::Todo, "todo"), + (crate::issue::Status::Doing, "doing"), + (crate::issue::Status::Done, "done"), + (crate::issue::Status::Skip, "skip"), + ] + .iter() + .filter(|(s, _)| app.all_status_filter.contains(s)) + .map(|(_, name)| *name) + .collect(); + format!("Status: {}", statuses.join(", ")) + }; + let status = Paragraph::new(status_line).style(Style::default().fg(Color::Cyan)); + f.render_widget(status, chunks[2]); + } InputMode::Normal => {} } }