a73x

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());
    }
}