a314db12
Add dashboard filtering: text search and status filter cycling
a73x 2026-03-21 08:45
Add '/' key to activate search mode with real-time case-insensitive title filtering across issues and patches. Replace show_all boolean with StatusFilter enum (Open/Closed/All) cycled via 'a' key. Footer shows active filter state and search input. Includes 8 unit tests. Fixes: 3f99448a Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
diff --git a/src/tui.rs b/src/tui.rs index d0f92f2..3c5fbdb 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -31,6 +31,31 @@ enum ViewMode { Diff, } #[derive(Debug, PartialEq, Clone, Copy)] enum StatusFilter { Open, Closed, All, } impl StatusFilter { fn next(self) -> Self { match self { StatusFilter::Open => StatusFilter::All, StatusFilter::All => StatusFilter::Closed, StatusFilter::Closed => StatusFilter::Open, } } fn label(self) -> &'static str { match self { StatusFilter::Open => "open", StatusFilter::Closed => "closed", StatusFilter::All => "all", } } } struct App { tab: Tab, issues: Vec<IssueState>, @@ -40,7 +65,9 @@ struct App { scroll: u16, pane: Pane, mode: ViewMode, show_all: bool, status_filter: StatusFilter, search_query: String, search_active: bool, status_msg: Option<String>, } @@ -59,47 +86,51 @@ impl App { scroll: 0, pane: Pane::ItemList, mode: ViewMode::Details, show_all: false, status_filter: StatusFilter::Open, search_query: String::new(), search_active: false, status_msg: None, } } fn visible_issue_count(&self) -> usize { if self.show_all { self.issues.len() } else { self.issues .iter() .filter(|i| i.status == IssueStatus::Open) .count() fn matches_search(&self, title: &str) -> bool { if self.search_query.is_empty() { return true; } title .to_lowercase() .contains(&self.search_query.to_lowercase()) } fn visible_issues(&self) -> Vec<&IssueState> { if self.show_all { self.issues.iter().collect() } else { self.issues .iter() .filter(|i| i.status == IssueStatus::Open) .collect() } self.issues .iter() .filter(|i| match self.status_filter { StatusFilter::Open => i.status == IssueStatus::Open, StatusFilter::Closed => i.status == IssueStatus::Closed, StatusFilter::All => true, }) .filter(|i| self.matches_search(&i.title)) .collect() } fn visible_patches(&self) -> Vec<&PatchState> { if self.show_all { self.patches.iter().collect() } else { self.patches .iter() .filter(|p| p.status == PatchStatus::Open) .collect() } self.patches .iter() .filter(|p| match self.status_filter { StatusFilter::Open => p.status == PatchStatus::Open, StatusFilter::Closed => { p.status == PatchStatus::Closed || p.status == PatchStatus::Merged } StatusFilter::All => true, }) .filter(|p| self.matches_search(&p.title)) .collect() } fn visible_count(&self) -> usize { match self.tab { Tab::Issues => self.visible_issue_count(), Tab::Issues => self.visible_issues().len(), Tab::Patches => self.visible_patches().len(), } } @@ -148,13 +179,13 @@ impl App { .enumerate() .find(|(_, p)| p.fixes.as_deref() == Some(&issue_id)); if let Some((patch_idx, _)) = target { if !self.show_all { if self.status_filter != StatusFilter::All { let visible_patches = self.visible_patches(); if !visible_patches .iter() .any(|p| p.fixes.as_deref() == Some(&issue_id)) { self.show_all = true; self.status_filter = StatusFilter::All; } } let visible_patches = self.visible_patches(); @@ -180,10 +211,10 @@ impl App { if let Some(patch) = visible.get(idx) { if let Some(ref fixes_id) = patch.fixes { let fixes_id = fixes_id.clone(); if !self.show_all { if self.status_filter != StatusFilter::All { let visible_issues = self.visible_issues(); if !visible_issues.iter().any(|i| i.id == fixes_id) { self.show_all = true; self.status_filter = StatusFilter::All; } } let visible_issues = self.visible_issues(); @@ -273,11 +304,43 @@ fn run_loop( if event::poll(Duration::from_millis(100))? { if let Event::Key(key) = event::read()? { app.status_msg = None; // clear status on any keypress // Search mode intercept — handle keys before normal bindings if app.search_active { match key.code { KeyCode::Esc => { app.search_active = false; app.search_query.clear(); let count = app.visible_count(); app.list_state .select(if count > 0 { Some(0) } else { None }); } KeyCode::Backspace => { app.search_query.pop(); let count = app.visible_count(); app.list_state .select(if count > 0 { Some(0) } else { None }); } KeyCode::Char(c) => { app.search_query.push(c); let count = app.visible_count(); app.list_state .select(if count > 0 { Some(0) } else { None }); } _ => {} } continue; } match key.code { KeyCode::Char('q') | KeyCode::Esc => return Ok(()), KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { return Ok(()) } KeyCode::Char('/') => { app.search_active = true; app.search_query.clear(); } KeyCode::Char('1') => app.switch_tab(Tab::Issues), KeyCode::Char('2') => app.switch_tab(Tab::Patches), KeyCode::Char('j') | KeyCode::Down => { @@ -312,7 +375,7 @@ fn run_loop( } } KeyCode::Char('a') => { app.show_all = !app.show_all; app.status_filter = app.status_filter.next(); let count = app.visible_count(); app.list_state .select(if count > 0 { Some(0) } else { None }); @@ -449,11 +512,7 @@ fn render_list(frame: &mut Frame, app: &mut App, area: Rect) { }) .collect(); let title = if app.show_all { "Issues (all)" } else { "Issues (open)" }; let title = format!("Issues ({})", app.status_filter.label()); let list = List::new(items) .block( @@ -490,11 +549,7 @@ fn render_list(frame: &mut Frame, app: &mut App, area: Rect) { }) .collect(); let title = if app.show_all { "Patches (all)" } else { "Patches (open)" }; let title = format!("Patches ({})", app.status_filter.label()); let list = List::new(items) .block( @@ -534,7 +589,7 @@ fn render_detail(frame: &mut Frame, app: &App, area: Rect) { let selected_idx = app.list_state.selected().unwrap_or(0); match visible.get(selected_idx) { Some(issue) => build_issue_detail(issue, &app.patches), None => Text::raw("No issues to display."), None => Text::raw("No matches for current filter."), } } Tab::Patches => { @@ -552,7 +607,7 @@ fn render_detail(frame: &mut Frame, app: &App, area: Rect) { colorize_diff(diff_text, &patch.inline_comments) } }, None => Text::raw("No patches to display."), None => Text::raw("No matches for current filter."), } } }; @@ -969,6 +1024,20 @@ fn colorize_diff(diff: &str, inline_comments: &[state::InlineComment]) -> Text<' } fn render_footer(frame: &mut Frame, app: &App, area: Rect) { if app.search_active { let max_query_len = (area.width as usize).saturating_sub(12); let display_query = if app.search_query.len() > max_query_len { &app.search_query[app.search_query.len() - max_query_len..] } else { &app.search_query }; let text = format!(" Search: {}_", display_query); let style = Style::default().bg(Color::Blue).fg(Color::White); let para = Paragraph::new(text).style(style); frame.render_widget(para, area); return; } let mode_hint = if app.tab == Tab::Patches { match app.mode { ViewMode::Details => " d:diff", @@ -977,16 +1046,16 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) { } else { "" }; let filter_hint = if app.show_all { "a:open only" } else { "a:show all" let filter_hint = match app.status_filter { StatusFilter::Open => "a:show all", StatusFilter::All => "a:closed", StatusFilter::Closed => "a:open only", }; let text = if let Some(ref msg) = app.status_msg { format!(" {}", msg) } else { format!( " 1:issues 2:patches j/k:navigate Tab:pane {}{} g:follow o:checkout r:refresh q:quit", " 1:issues 2:patches j/k:navigate Tab:pane {}{} /:search g:follow o:checkout r:refresh q:quit", filter_hint, mode_hint ) }; @@ -998,3 +1067,157 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) { let para = Paragraph::new(text).style(style); frame.render_widget(para, area); } #[cfg(test)] mod tests { use super::*; use crate::event::Author; fn make_author() -> Author { Author { name: "test".into(), email: "test@test.com".into(), } } fn make_issue(id: &str, title: &str, status: IssueStatus) -> IssueState { IssueState { id: id.into(), title: title.into(), body: String::new(), status, close_reason: None, closed_by: None, labels: vec![], assignees: vec![], comments: vec![], created_at: String::new(), author: make_author(), } } fn make_patch(id: &str, title: &str, status: PatchStatus) -> PatchState { PatchState { id: id.into(), title: title.into(), body: String::new(), status, base_ref: "main".into(), head_commit: "abc123".into(), fixes: None, comments: vec![], inline_comments: vec![], reviews: vec![], created_at: String::new(), author: make_author(), } } fn test_app() -> App { let issues = vec![ make_issue("i1", "Fix login bug", IssueStatus::Open), make_issue("i2", "Add dashboard feature", IssueStatus::Open), make_issue("i3", "Old closed issue", IssueStatus::Closed), ]; let patches = vec![ make_patch("p1", "Login fix patch", PatchStatus::Open), make_patch("p2", "Dashboard patch", PatchStatus::Closed), make_patch("p3", "Merged feature", PatchStatus::Merged), ]; App::new(issues, patches) } // T010: visible_issues filters by search_query (case-insensitive) #[test] fn test_visible_issues_text_filter() { let mut app = test_app(); app.status_filter = StatusFilter::All; app.search_query = "login".into(); let visible = app.visible_issues(); assert_eq!(visible.len(), 1); assert_eq!(visible[0].title, "Fix login bug"); } // T011: visible_patches filters by search_query (case-insensitive) #[test] fn test_visible_patches_text_filter() { let mut app = test_app(); app.status_filter = StatusFilter::All; app.search_query = "DASHBOARD".into(); let visible = app.visible_patches(); assert_eq!(visible.len(), 1); assert_eq!(visible[0].title, "Dashboard patch"); } // T012: visible_issues returns all status-matching items when search_query is empty #[test] fn test_visible_issues_no_text_filter() { let mut app = test_app(); app.status_filter = StatusFilter::All; app.search_query.clear(); let visible = app.visible_issues(); assert_eq!(visible.len(), 3); } // T019: StatusFilter::next() cycles Open → All → Closed → Open #[test] fn test_status_filter_cycle() { assert_eq!(StatusFilter::Open.next(), StatusFilter::All); assert_eq!(StatusFilter::All.next(), StatusFilter::Closed); assert_eq!(StatusFilter::Closed.next(), StatusFilter::Open); } // T020: visible_issues returns only closed when status_filter is Closed #[test] fn test_visible_issues_closed_filter() { let mut app = test_app(); app.status_filter = StatusFilter::Closed; let visible = app.visible_issues(); assert_eq!(visible.len(), 1); assert_eq!(visible[0].title, "Old closed issue"); } // T021: visible_patches returns closed AND merged when status_filter is Closed #[test] fn test_visible_patches_closed_filter() { let mut app = test_app(); app.status_filter = StatusFilter::Closed; let visible = app.visible_patches(); assert_eq!(visible.len(), 2); assert!(visible.iter().any(|p| p.status == PatchStatus::Closed)); assert!(visible.iter().any(|p| p.status == PatchStatus::Merged)); } // T025: combined status + text filter #[test] fn test_combined_filters() { let mut app = test_app(); app.status_filter = StatusFilter::Open; app.search_query = "login".into(); let visible = app.visible_issues(); assert_eq!(visible.len(), 1); assert_eq!(visible[0].title, "Fix login bug"); // Same query with Closed filter should return nothing app.status_filter = StatusFilter::Closed; let visible = app.visible_issues(); assert_eq!(visible.len(), 0); } // T026: Escape clears text filter but preserves status_filter #[test] fn test_escape_clears_text_preserves_status() { let mut app = test_app(); app.status_filter = StatusFilter::Closed; app.search_active = true; app.search_query = "some query".into(); // Simulate Escape app.search_active = false; app.search_query.clear(); assert_eq!(app.status_filter, StatusFilter::Closed); assert!(!app.search_active); assert!(app.search_query.is_empty()); } }