a73x

src/tui/mod.rs

Ref:   Size: 45.6 KiB

mod events;
mod state;
mod widgets;

use std::io::stdout;

use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen};
use crossterm::ExecutableCommand;
use git2::Repository;
use ratatui::prelude::*;

use crate::error::Error;
use crate::state as app_state;

use self::events::run_loop;
use self::state::App;

pub fn run(repo: &Repository) -> Result<(), Error> {
    let issues = app_state::list_issues(repo)?;
    let patches = app_state::list_patches(repo)?;

    let mut app = App::new(issues, patches);

    terminal::enable_raw_mode()?;
    stdout().execute(EnterAlternateScreen)?;
    let mut terminal = Terminal::new(CrosstermBackend::new(stdout()))?;

    let result = run_loop(&mut terminal, &mut app, repo);

    terminal::disable_raw_mode()?;
    stdout().execute(LeaveAlternateScreen)?;

    result
}

#[cfg(test)]
mod tests {
    use super::state::*;
    use super::widgets::*;
    use crate::event::{Action, Author, ReviewVerdict};
    use crate::state::{IssueState, IssueStatus, PatchState, PatchStatus};
    use git2::Oid;
    use ratatui::backend::TestBackend;
    use ratatui::buffer::Buffer;
    use ratatui::prelude::*;
    use ratatui::widgets::ListState;

    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![],
            linked_commits: vec![],
            created_at: String::new(),
            last_updated: String::new(),
            author: make_author(),
            relates_to: None,
        }
    }

    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(),
            fixes: None,
            branch: format!("feature/{}", id),
            base_commit: None,
            comments: vec![],
            inline_comments: vec![],
            reviews: vec![],
            revisions: vec![],
            created_at: String::new(),
            last_updated: 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");
    }

    // 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");
    }

    // 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.input_mode = InputMode::Search;
        app.search_query = "some query".into();

        // Simulate Escape
        app.input_mode = InputMode::Normal;
        app.search_query.clear();

        assert_eq!(app.status_filter, StatusFilter::Closed);
        assert_eq!(app.input_mode, InputMode::Normal);
        assert!(app.search_query.is_empty());
    }

    #[test]
    fn test_create_title_mode_clears_on_escape() {
        let mut app = test_app();
        app.input_mode = InputMode::CreateTitle;
        app.input_buf = "partial title".into();

        // Simulate Escape
        app.input_mode = InputMode::Normal;
        app.input_buf.clear();

        assert_eq!(app.input_mode, InputMode::Normal);
        assert!(app.input_buf.is_empty());
    }

    #[test]
    fn test_create_title_transitions_to_body() {
        let mut app = test_app();
        app.input_mode = InputMode::CreateTitle;
        app.input_buf = "My new issue".into();

        // Simulate Enter with non-empty title
        let title = app.input_buf.trim().to_string();
        assert!(!title.is_empty());
        app.create_title = title;
        app.input_buf.clear();
        app.input_mode = InputMode::CreateBody;

        assert_eq!(app.input_mode, InputMode::CreateBody);
        assert_eq!(app.create_title, "My new issue");
        assert!(app.input_buf.is_empty());
    }

    #[test]
    fn test_empty_title_dismissed() {
        let mut app = test_app();
        app.input_mode = InputMode::CreateTitle;
        app.input_buf = "   ".into();

        // Simulate Enter with whitespace-only title
        let title = app.input_buf.trim().to_string();
        if title.is_empty() {
            app.input_mode = InputMode::Normal;
            app.input_buf.clear();
        }

        assert_eq!(app.input_mode, InputMode::Normal);
    }

    // ── Commit browser test helpers ─────────────────────────────────────

    fn test_author() -> Author {
        Author {
            name: "Test User".to_string(),
            email: "test@example.com".to_string(),
        }
    }

    fn make_test_issues(n: usize) -> Vec<IssueState> {
        (0..n)
            .map(|i| IssueState {
                id: format!("{:08x}", i),
                title: format!("Issue {}", i),
                body: format!("Body for issue {}", i),
                status: if i % 2 == 0 {
                    IssueStatus::Open
                } else {
                    IssueStatus::Closed
                },
                close_reason: if i % 2 == 1 {
                    Some("done".to_string())
                } else {
                    None
                },
                closed_by: None,
                labels: vec![],
                assignees: vec![],
                comments: Vec::new(),
                linked_commits: Vec::new(),
                created_at: "2026-01-01T00:00:00Z".to_string(),
                last_updated: "2026-01-01T00:00:00Z".to_string(),
                author: test_author(),
                relates_to: None,
            })
            .collect()
    }

    fn make_test_patches(n: usize) -> Vec<PatchState> {
        (0..n)
            .map(|i| PatchState {
                id: format!("p{:07x}", i),
                title: format!("Patch {}", i),
                body: format!("Body for patch {}", i),
                status: if i % 2 == 0 {
                    PatchStatus::Open
                } else {
                    PatchStatus::Closed
                },
                base_ref: "main".to_string(),
                fixes: None,
                branch: format!("feature/p{:07x}", i),
                base_commit: None,
                comments: Vec::new(),
                inline_comments: Vec::new(),
                reviews: Vec::new(),
                revisions: Vec::new(),
                created_at: "2026-01-01T00:00:00Z".to_string(),
                last_updated: "2026-01-01T00:00:00Z".to_string(),
                author: test_author(),
            })
            .collect()
    }

    fn make_app(issues: usize, patches: usize) -> App {
        App::new(make_test_issues(issues), make_test_patches(patches))
    }

    fn render_app(app: &mut App) -> Buffer {
        let backend = TestBackend::new(80, 24);
        let mut terminal = Terminal::new(backend).unwrap();
        terminal.draw(|frame| ui(frame, app, None)).unwrap();
        terminal.backend().buffer().clone()
    }

    fn buffer_to_string(buf: &Buffer) -> String {
        let area = buf.area;
        let mut s = String::new();
        for y in area.y..area.y + area.height {
            for x in area.x..area.x + area.width {
                let cell = buf.cell((x, y)).unwrap();
                s.push_str(cell.symbol());
            }
            s.push('\n');
        }
        s
    }

    fn assert_buffer_contains(buf: &Buffer, expected: &str) {
        let text = buffer_to_string(buf);
        assert!(
            text.contains(expected),
            "expected buffer to contain {:?}, but it was not found in:\n{}",
            expected,
            text
        );
    }

    /// Create sample event history for testing commit browser
    fn make_test_event_history() -> Vec<(Oid, crate::event::Event)> {
        let oid1 = Oid::from_str("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
        let oid2 = Oid::from_str("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb").unwrap();
        let oid3 = Oid::from_str("cccccccccccccccccccccccccccccccccccccccc").unwrap();

        vec![
            (
                oid1,
                crate::event::Event {
                    timestamp: "2026-01-01T00:00:00Z".to_string(),
                    author: test_author(),
                    action: Action::IssueOpen {
                        title: "Test Issue".to_string(),
                        body: "This is the body".to_string(),
                        relates_to: None,
                    },
                    clock: 0,
                },
            ),
            (
                oid2,
                crate::event::Event {
                    timestamp: "2026-01-02T00:00:00Z".to_string(),
                    author: Author {
                        name: "Other User".to_string(),
                        email: "other@example.com".to_string(),
                    },
                    action: Action::IssueComment {
                        body: "A comment on the issue".to_string(),
                    },
                    clock: 0,
                },
            ),
            (
                oid3,
                crate::event::Event {
                    timestamp: "2026-01-03T00:00:00Z".to_string(),
                    author: test_author(),
                    action: Action::IssueClose {
                        reason: Some("fixed".to_string()),
                    },
                    clock: 0,
                },
            ),
        ]
    }

    // ── action_type_label tests ──────────────────────────────────────────

    #[test]
    fn test_action_type_label_issue_open() {
        let action = Action::IssueOpen {
            title: "t".to_string(),
            body: "b".to_string(),
            relates_to: None,
        };
        assert_eq!(action_type_label(&action), "Issue Open");
    }

    #[test]
    fn test_action_type_label_issue_comment() {
        let action = Action::IssueComment {
            body: "b".to_string(),
        };
        assert_eq!(action_type_label(&action), "Issue Comment");
    }

    #[test]
    fn test_action_type_label_issue_close() {
        let action = Action::IssueClose { reason: None };
        assert_eq!(action_type_label(&action), "Issue Close");
    }

    #[test]
    fn test_action_type_label_issue_reopen() {
        assert_eq!(action_type_label(&Action::IssueReopen), "Issue Reopen");
    }

    #[test]
    fn test_action_type_label_patch_create() {
        let action = Action::PatchCreate {
            title: "t".to_string(),
            body: "b".to_string(),
            base_ref: "main".to_string(),
            branch: "feature/test".to_string(),
            fixes: None,
            commit: "abc123".to_string(),
            tree: "def456".to_string(),
            base_commit: None,
        };
        assert_eq!(action_type_label(&action), "Patch Create");
    }

    #[test]
    fn test_action_type_label_patch_review() {
        let action = Action::PatchReview {
            verdict: ReviewVerdict::Approve,
            body: "lgtm".to_string(),
            revision: 1,
        };
        assert_eq!(action_type_label(&action), "Patch Review");
    }

    #[test]
    fn test_action_type_label_inline_comment() {
        let action = Action::PatchInlineComment {
            file: "src/main.rs".to_string(),
            line: 42,
            body: "nit".to_string(),
            revision: 1,
        };
        assert_eq!(action_type_label(&action), "Inline Comment");
    }

    #[test]
    fn test_action_type_label_merge() {
        assert_eq!(action_type_label(&Action::Merge), "Merge");
    }

    // ── format_event_detail tests ────────────────────────────────────────

    #[test]
    fn test_format_event_detail_issue_open() {
        let oid = Oid::from_str("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
        let event = crate::event::Event {
            timestamp: "2026-01-01T00:00:00Z".to_string(),
            author: test_author(),
            action: Action::IssueOpen {
                title: "My Issue".to_string(),
                body: "Description here".to_string(),
                relates_to: None,
            },
            clock: 0,
        };
        let detail = format_event_detail(&oid, &event);
        assert!(detail.contains("aaaaaaa"));
        assert!(detail.contains("Test User <test@example.com>"));
        assert!(detail.contains("2026-01-01T00:00:00Z"));
        assert!(detail.contains("Issue Open"));
        assert!(detail.contains("Title: My Issue"));
        assert!(detail.contains("Description here"));
    }

    #[test]
    fn test_format_event_detail_issue_close_with_reason() {
        let oid = Oid::from_str("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb").unwrap();
        let event = crate::event::Event {
            timestamp: "2026-02-01T00:00:00Z".to_string(),
            author: test_author(),
            action: Action::IssueClose {
                reason: Some("resolved".to_string()),
            },
            clock: 0,
        };
        let detail = format_event_detail(&oid, &event);
        assert!(detail.contains("Issue Close"));
        assert!(detail.contains("Reason: resolved"));
    }

    #[test]
    fn test_format_event_detail_patch_review() {
        let oid = Oid::from_str("cccccccccccccccccccccccccccccccccccccccc").unwrap();
        let event = crate::event::Event {
            timestamp: "2026-03-01T00:00:00Z".to_string(),
            author: test_author(),
            action: Action::PatchReview {
                verdict: ReviewVerdict::Approve,
                body: "Looks good!".to_string(),
                revision: 1,
            },
            clock: 0,
        };
        let detail = format_event_detail(&oid, &event);
        assert!(detail.contains("Patch Review"));
        assert!(detail.contains("approve"));
        assert!(detail.contains("Looks good!"));
    }

    #[test]
    fn test_format_event_detail_short_oid() {
        let oid = Oid::from_str("1234567890abcdef1234567890abcdef12345678").unwrap();
        let event = crate::event::Event {
            timestamp: "2026-01-01T00:00:00Z".to_string(),
            author: test_author(),
            action: Action::IssueReopen,
            clock: 0,
        };
        let detail = format_event_detail(&oid, &event);
        assert!(detail.contains("1234567"));
        assert!(detail.contains("Commit:  1234567\n"));
    }

    // ── handle_key tests for 'c' key ─────────────────────────────────────

    #[test]
    fn test_handle_key_c_in_detail_pane_returns_open_commit_browser() {
        let mut app = make_app(3, 0);
        app.pane = Pane::Detail;
        app.list_state.select(Some(0));
        let result = app.handle_key(
            crossterm::event::KeyCode::Char('c'),
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(result, KeyAction::OpenCommitBrowser);
    }

    #[test]
    fn test_handle_key_c_in_item_list_pane_is_noop() {
        let mut app = make_app(3, 0);
        app.pane = Pane::ItemList;
        app.list_state.select(Some(0));
        let result = app.handle_key(
            crossterm::event::KeyCode::Char('c'),
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(result, KeyAction::Continue);
    }

    #[test]
    fn test_handle_key_c_no_selection_is_noop() {
        let mut app = make_app(0, 0);
        app.pane = Pane::Detail;
        let result = app.handle_key(
            crossterm::event::KeyCode::Char('c'),
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(result, KeyAction::Continue);
    }

    #[test]
    fn test_handle_key_ctrl_c_still_quits() {
        let mut app = make_app(3, 0);
        let result = app.handle_key(
            crossterm::event::KeyCode::Char('c'),
            crossterm::event::KeyModifiers::CONTROL,
        );
        assert_eq!(result, KeyAction::Quit);
    }

    // ── CommitList navigation tests ──────────────────────────────────────

    #[test]
    fn test_commit_list_navigate_down() {
        let mut app = make_app(3, 0);
        app.event_history = make_test_event_history();
        app.event_list_state.select(Some(0));
        app.mode = ViewMode::CommitList;

        app.handle_key(
            crossterm::event::KeyCode::Char('j'),
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(app.event_list_state.selected(), Some(1));
    }

    #[test]
    fn test_commit_list_navigate_up() {
        let mut app = make_app(3, 0);
        app.event_history = make_test_event_history();
        app.event_list_state.select(Some(2));
        app.mode = ViewMode::CommitList;

        app.handle_key(
            crossterm::event::KeyCode::Char('k'),
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(app.event_list_state.selected(), Some(1));
    }

    #[test]
    fn test_commit_list_navigate_clamp_bottom() {
        let mut app = make_app(3, 0);
        app.event_history = make_test_event_history();
        app.event_list_state.select(Some(2));
        app.mode = ViewMode::CommitList;

        app.handle_key(
            crossterm::event::KeyCode::Down,
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(app.event_list_state.selected(), Some(2));
    }

    #[test]
    fn test_commit_list_navigate_clamp_top() {
        let mut app = make_app(3, 0);
        app.event_history = make_test_event_history();
        app.event_list_state.select(Some(0));
        app.mode = ViewMode::CommitList;

        app.handle_key(
            crossterm::event::KeyCode::Up,
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(app.event_list_state.selected(), Some(0));
    }

    #[test]
    fn test_commit_list_escape_returns_to_details() {
        let mut app = make_app(3, 0);
        app.event_history = make_test_event_history();
        app.event_list_state.select(Some(1));
        app.mode = ViewMode::CommitList;

        let result = app.handle_key(
            crossterm::event::KeyCode::Esc,
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(result, KeyAction::Continue);
        assert_eq!(app.mode, ViewMode::Details);
        assert!(app.event_history.is_empty());
        assert_eq!(app.event_list_state.selected(), None);
    }

    #[test]
    fn test_commit_list_q_quits() {
        let mut app = make_app(3, 0);
        app.mode = ViewMode::CommitList;
        let result = app.handle_key(
            crossterm::event::KeyCode::Char('q'),
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(result, KeyAction::Quit);
    }

    // ── CommitDetail tests ───────────────────────────────────────────────

    #[test]
    fn test_commit_list_enter_opens_detail() {
        let mut app = make_app(3, 0);
        app.event_history = make_test_event_history();
        app.event_list_state.select(Some(1));
        app.mode = ViewMode::CommitList;

        let result = app.handle_key(
            crossterm::event::KeyCode::Enter,
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(result, KeyAction::Continue);
        assert_eq!(app.mode, ViewMode::CommitDetail);
        assert_eq!(app.scroll, 0);
    }

    #[test]
    fn test_commit_list_enter_no_selection_stays() {
        let mut app = make_app(3, 0);
        app.event_history = make_test_event_history();
        app.event_list_state = ListState::default();
        app.mode = ViewMode::CommitList;

        app.handle_key(
            crossterm::event::KeyCode::Enter,
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(app.mode, ViewMode::CommitList);
    }

    #[test]
    fn test_commit_detail_escape_returns_to_list() {
        let mut app = make_app(3, 0);
        app.event_history = make_test_event_history();
        app.event_list_state.select(Some(0));
        app.mode = ViewMode::CommitDetail;
        app.scroll = 5;

        let result = app.handle_key(
            crossterm::event::KeyCode::Esc,
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(result, KeyAction::Continue);
        assert_eq!(app.mode, ViewMode::CommitList);
        assert_eq!(app.scroll, 0);
        assert_eq!(app.event_history.len(), 3);
    }

    #[test]
    fn test_commit_detail_scroll() {
        let mut app = make_app(3, 0);
        app.mode = ViewMode::CommitDetail;
        app.scroll = 0;

        app.handle_key(
            crossterm::event::KeyCode::Char('j'),
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(app.scroll, 1);
        app.handle_key(
            crossterm::event::KeyCode::Char('j'),
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(app.scroll, 2);
        app.handle_key(
            crossterm::event::KeyCode::Char('k'),
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(app.scroll, 1);
    }

    #[test]
    fn test_commit_detail_page_scroll() {
        let mut app = make_app(3, 0);
        app.mode = ViewMode::CommitDetail;
        app.scroll = 0;

        app.handle_key(
            crossterm::event::KeyCode::PageDown,
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(app.scroll, 20);
        app.handle_key(
            crossterm::event::KeyCode::PageUp,
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(app.scroll, 0);
    }

    #[test]
    fn test_commit_detail_q_quits() {
        let mut app = make_app(3, 0);
        app.mode = ViewMode::CommitDetail;
        let result = app.handle_key(
            crossterm::event::KeyCode::Char('q'),
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(result, KeyAction::Quit);
    }

    // ── Guard tests ──────────────────────────────────────────────────────

    #[test]
    fn test_c_ignored_in_commit_list_mode() {
        let mut app = make_app(3, 0);
        app.mode = ViewMode::CommitList;
        let result = app.handle_key(
            crossterm::event::KeyCode::Char('c'),
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(result, KeyAction::Continue);
        assert_eq!(app.mode, ViewMode::CommitList);
    }

    #[test]
    fn test_c_ignored_in_commit_detail_mode() {
        let mut app = make_app(3, 0);
        app.mode = ViewMode::CommitDetail;
        let result = app.handle_key(
            crossterm::event::KeyCode::Char('c'),
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(result, KeyAction::Continue);
        assert_eq!(app.mode, ViewMode::CommitDetail);
    }

    // ── handle_key basic tests ───────────────────────────────────────────

    #[test]
    fn test_handle_key_quit() {
        let mut app = make_app(3, 3);
        assert_eq!(
            app.handle_key(
                crossterm::event::KeyCode::Char('q'),
                crossterm::event::KeyModifiers::empty()
            ),
            KeyAction::Quit
        );
    }

    #[test]
    fn test_handle_key_quit_esc() {
        let mut app = make_app(3, 3);
        assert_eq!(
            app.handle_key(
                crossterm::event::KeyCode::Esc,
                crossterm::event::KeyModifiers::empty()
            ),
            KeyAction::Quit
        );
    }

    #[test]
    fn test_handle_key_quit_ctrl_c() {
        let mut app = make_app(3, 3);
        assert_eq!(
            app.handle_key(
                crossterm::event::KeyCode::Char('c'),
                crossterm::event::KeyModifiers::CONTROL
            ),
            KeyAction::Quit
        );
    }

    #[test]
    fn test_handle_key_pane_toggle() {
        let mut app = make_app(3, 3);
        assert_eq!(app.pane, Pane::ItemList);
        app.handle_key(
            crossterm::event::KeyCode::Tab,
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(app.pane, Pane::Detail);
        app.handle_key(
            crossterm::event::KeyCode::Enter,
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(app.pane, Pane::ItemList);
    }

    #[test]
    fn test_scroll_in_detail_pane() {
        let mut app = make_app(3, 0);
        app.list_state.select(Some(0));
        app.pane = Pane::Detail;
        app.scroll = 0;

        app.handle_key(
            crossterm::event::KeyCode::Char('j'),
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(app.scroll, 1);
        assert_eq!(app.list_state.selected(), Some(0));

        app.handle_key(
            crossterm::event::KeyCode::Char('k'),
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(app.scroll, 0);
    }

    #[test]
    fn test_handle_key_reload() {
        let mut app = make_app(3, 3);
        assert_eq!(
            app.handle_key(
                crossterm::event::KeyCode::Char('r'),
                crossterm::event::KeyModifiers::empty()
            ),
            KeyAction::Reload
        );
    }

    // ── selected_ref_name tests ──────────────────────────────────────────

    #[test]
    fn test_selected_ref_name_issues() {
        let app = make_app(3, 0);
        let ref_name = app.selected_ref_name();
        assert_eq!(
            ref_name,
            Some("refs/collab/issues/00000000".to_string())
        );
    }

    #[test]
    fn test_selected_ref_name_none_when_empty() {
        let app = make_app(0, 0);
        assert_eq!(app.selected_ref_name(), None);
    }

    // ── Render tests ─────────────────────────────────────────────────────

    #[test]
    fn test_render_issues_tab() {
        let mut app = make_app(3, 2);
        app.status_filter = StatusFilter::All;
        let buf = render_app(&mut app);
        assert_buffer_contains(&buf, "Issues");
        assert_buffer_contains(&buf, "00000000");
        assert_buffer_contains(&buf, "00000001");
        assert_buffer_contains(&buf, "00000002");
        assert_buffer_contains(&buf, "Issue 0");
    }

    #[test]
    fn test_render_empty_state() {
        let mut app = make_app(0, 0);
        let buf = render_app(&mut app);
        assert_buffer_contains(&buf, "No matches for current filter.");
    }

    #[test]
    fn test_render_footer_keys() {
        let mut app = make_app(3, 3);
        let buf = render_app(&mut app);
        assert_buffer_contains(&buf, "j/k:navigate");
        assert_buffer_contains(&buf, "Tab:pane");
        assert_buffer_contains(&buf, "c:events");
    }

    #[test]
    fn test_render_commit_list() {
        let mut app = make_app(3, 0);
        app.event_history = make_test_event_history();
        app.event_list_state.select(Some(0));
        app.mode = ViewMode::CommitList;
        let buf = render_app(&mut app);
        assert_buffer_contains(&buf, "Event History");
        assert_buffer_contains(&buf, "Issue Open");
        assert_buffer_contains(&buf, "Issue Comment");
        assert_buffer_contains(&buf, "Issue Close");
    }

    #[test]
    fn test_render_commit_detail() {
        let mut app = make_app(3, 0);
        app.event_history = make_test_event_history();
        app.event_list_state.select(Some(0));
        app.mode = ViewMode::CommitDetail;
        let buf = render_app(&mut app);
        assert_buffer_contains(&buf, "Event Detail");
        assert_buffer_contains(&buf, "aaaaaaa");
        assert_buffer_contains(&buf, "Test User");
        assert_buffer_contains(&buf, "Issue Open");
    }

    #[test]
    fn test_render_commit_list_footer() {
        let mut app = make_app(3, 0);
        app.mode = ViewMode::CommitList;
        let buf = render_app(&mut app);
        assert_buffer_contains(&buf, "Esc:back");
    }

    #[test]
    fn test_render_commit_detail_footer() {
        let mut app = make_app(3, 0);
        app.mode = ViewMode::CommitDetail;
        let buf = render_app(&mut app);
        assert_buffer_contains(&buf, "Esc:back");
        assert_buffer_contains(&buf, "j/k:scroll");
    }

    #[test]
    fn test_render_small_terminal() {
        let mut app = make_app(3, 3);
        let backend = TestBackend::new(20, 10);
        let mut terminal = Terminal::new(backend).unwrap();
        terminal.draw(|frame| ui(frame, &mut app, None)).unwrap();
    }

    // ── Integration: full browse flow ────────────────────────────────────

    #[test]
    fn test_full_commit_browse_flow() {
        let mut app = make_app(3, 0);
        app.pane = Pane::Detail;
        app.list_state.select(Some(0));

        let action = app.handle_key(
            crossterm::event::KeyCode::Char('c'),
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(action, KeyAction::OpenCommitBrowser);

        app.event_history = make_test_event_history();
        app.event_list_state.select(Some(0));
        app.mode = ViewMode::CommitList;
        app.scroll = 0;

        app.handle_key(
            crossterm::event::KeyCode::Char('j'),
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(app.event_list_state.selected(), Some(1));

        app.handle_key(
            crossterm::event::KeyCode::Enter,
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(app.mode, ViewMode::CommitDetail);

        app.handle_key(
            crossterm::event::KeyCode::Char('j'),
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(app.scroll, 1);

        app.handle_key(
            crossterm::event::KeyCode::Esc,
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(app.mode, ViewMode::CommitList);
        assert_eq!(app.scroll, 0);

        app.handle_key(
            crossterm::event::KeyCode::Esc,
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(app.mode, ViewMode::Details);
        assert!(app.event_history.is_empty());
    }

    // ── Status message render test ───────────────────────────────────────

    #[test]
    fn test_render_status_message() {
        let mut app = make_app(3, 0);
        app.status_msg = Some("Error loading events: ref not found".to_string());
        let buf = render_app(&mut app);
        assert_buffer_contains(&buf, "Error loading events");
    }

    // ── Patch detail view tests ─────────────────────────────────────────

    fn make_patch_with_revisions() -> PatchState {
        use crate::state::Revision;
        PatchState {
            id: "deadbeef".into(),
            title: "Fix the thing".into(),
            body: "Detailed description".into(),
            status: PatchStatus::Open,
            base_ref: "main".into(),
            fixes: Some("i1".into()),
            branch: "feature/fix-thing".into(),
            base_commit: None,
            comments: vec![crate::state::Comment {
                author: make_author(),
                body: "Thread comment".into(),
                timestamp: "2026-01-05T00:00:00Z".into(),
                commit_id: Oid::from_str("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap(),
            }],
            inline_comments: vec![crate::state::InlineComment {
                author: make_author(),
                file: "src/main.rs".into(),
                line: 42,
                body: "Nit: rename this".into(),
                timestamp: "2026-01-03T00:00:00Z".into(),
                revision: Some(1),
            }],
            reviews: vec![crate::state::Review {
                author: make_author(),
                verdict: ReviewVerdict::Approve,
                body: "LGTM".into(),
                timestamp: "2026-01-04T00:00:00Z".into(),
                revision: Some(2),
            }],
            revisions: vec![
                Revision {
                    number: 1,
                    commit: "aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111".into(),
                    tree: "bbbb1111bbbb1111bbbb1111bbbb1111bbbb1111".into(),
                    body: None,
                    timestamp: "2026-01-01T00:00:00Z".into(),
                },
                Revision {
                    number: 2,
                    commit: "aaaa2222aaaa2222aaaa2222aaaa2222aaaa2222".into(),
                    tree: "bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222".into(),
                    body: Some("Addressed review comments".into()),
                    timestamp: "2026-01-02T00:00:00Z".into(),
                },
            ],
            created_at: "2026-01-01T00:00:00Z".into(),
            last_updated: "2026-01-04T00:00:00Z".into(),
            author: make_author(),
        }
    }

    #[test]
    fn test_p_key_opens_patch_detail_when_linked_patch_exists() {
        let mut app = test_app();
        // Issue i1 has a linked patch (p1 has fixes=None, so we need to set it)
        app.patches[0].fixes = Some("i1".into());
        app.pane = Pane::Detail;
        app.list_state.select(Some(0)); // selects issue i1

        let result = app.handle_key(
            crossterm::event::KeyCode::Char('p'),
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(result, KeyAction::OpenPatchDetail);
    }

    #[test]
    fn test_p_key_noop_when_no_linked_patch() {
        let mut app = test_app();
        // No patches fix any issues by default
        app.pane = Pane::Detail;
        app.list_state.select(Some(0));

        let result = app.handle_key(
            crossterm::event::KeyCode::Char('p'),
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(result, KeyAction::Continue);
    }

    #[test]
    fn test_p_key_noop_in_item_list_pane() {
        let mut app = test_app();
        app.patches[0].fixes = Some("i1".into());
        app.pane = Pane::ItemList;
        app.list_state.select(Some(0));

        let result = app.handle_key(
            crossterm::event::KeyCode::Char('p'),
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(result, KeyAction::Continue);
    }

    #[test]
    fn test_patch_detail_esc_returns_to_details() {
        let mut app = test_app();
        app.current_patch = Some(make_patch_with_revisions());
        app.mode = ViewMode::PatchDetail;
        app.patch_scroll = 5;
        app.patch_revision_idx = 1;
        app.patch_interdiff_mode = true;
        app.patch_diff = "some diff".into();

        let result = app.handle_key(
            crossterm::event::KeyCode::Esc,
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(result, KeyAction::Continue);
        assert_eq!(app.mode, ViewMode::Details);
        assert!(app.current_patch.is_none());
        assert!(app.patch_diff.is_empty());
        assert_eq!(app.patch_scroll, 0);
        assert_eq!(app.patch_revision_idx, 0);
        assert!(!app.patch_interdiff_mode);
    }

    #[test]
    fn test_patch_detail_q_quits() {
        let mut app = test_app();
        app.mode = ViewMode::PatchDetail;
        let result = app.handle_key(
            crossterm::event::KeyCode::Char('q'),
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(result, KeyAction::Quit);
    }

    #[test]
    fn test_patch_detail_scroll() {
        let mut app = test_app();
        app.mode = ViewMode::PatchDetail;
        app.current_patch = Some(make_patch_with_revisions());
        app.patch_scroll = 0;

        app.handle_key(
            crossterm::event::KeyCode::Char('j'),
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(app.patch_scroll, 1);
        app.handle_key(
            crossterm::event::KeyCode::Char('j'),
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(app.patch_scroll, 2);
        app.handle_key(
            crossterm::event::KeyCode::Char('k'),
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(app.patch_scroll, 1);
    }

    #[test]
    fn test_patch_detail_page_scroll() {
        let mut app = test_app();
        app.mode = ViewMode::PatchDetail;
        app.patch_scroll = 0;

        app.handle_key(
            crossterm::event::KeyCode::PageDown,
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(app.patch_scroll, 20);
        app.handle_key(
            crossterm::event::KeyCode::PageUp,
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(app.patch_scroll, 0);
    }

    #[test]
    fn test_patch_detail_revision_navigation() {
        let mut app = test_app();
        app.mode = ViewMode::PatchDetail;
        app.current_patch = Some(make_patch_with_revisions());
        app.patch_revision_idx = 0;

        // Navigate forward
        let result = app.handle_key(
            crossterm::event::KeyCode::Char(']'),
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(result, KeyAction::Reload);
        assert_eq!(app.patch_revision_idx, 1);
        assert_eq!(app.patch_scroll, 0);

        // Can't go past last revision
        let result = app.handle_key(
            crossterm::event::KeyCode::Char(']'),
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(result, KeyAction::Continue);
        assert_eq!(app.patch_revision_idx, 1);

        // Navigate backward
        let result = app.handle_key(
            crossterm::event::KeyCode::Char('['),
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(result, KeyAction::Reload);
        assert_eq!(app.patch_revision_idx, 0);

        // Can't go below 0
        let result = app.handle_key(
            crossterm::event::KeyCode::Char('['),
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(result, KeyAction::Continue);
        assert_eq!(app.patch_revision_idx, 0);
    }

    #[test]
    fn test_patch_detail_interdiff_toggle() {
        let mut app = test_app();
        app.mode = ViewMode::PatchDetail;
        app.current_patch = Some(make_patch_with_revisions());
        app.patch_interdiff_mode = false;

        let result = app.handle_key(
            crossterm::event::KeyCode::Char('d'),
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(result, KeyAction::Reload);
        assert!(app.patch_interdiff_mode);
        assert_eq!(app.patch_scroll, 0);

        let result = app.handle_key(
            crossterm::event::KeyCode::Char('d'),
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(result, KeyAction::Reload);
        assert!(!app.patch_interdiff_mode);
    }

    #[test]
    fn test_render_patch_detail() {
        let mut app = make_app(3, 0);
        app.mode = ViewMode::PatchDetail;
        app.current_patch = Some(make_patch_with_revisions());
        app.patch_revision_idx = 1;
        app.patch_diff = "+added line\n-removed line\n context".into();

        let buf = render_app(&mut app);
        assert_buffer_contains(&buf, "Patch Detail");
        assert_buffer_contains(&buf, "Fix the thing");
        assert_buffer_contains(&buf, "deadbeef");
        assert_buffer_contains(&buf, "feature/fix-thing");
    }

    #[test]
    fn test_render_patch_detail_shows_reviews() {
        let mut app = make_app(3, 0);
        app.mode = ViewMode::PatchDetail;
        app.current_patch = Some(make_patch_with_revisions());
        app.patch_revision_idx = 1;
        app.patch_diff = String::new();

        let buf = render_app(&mut app);
        assert_buffer_contains(&buf, "Reviews");
        assert_buffer_contains(&buf, "approve");
        assert_buffer_contains(&buf, "LGTM");
    }

    #[test]
    fn test_render_patch_detail_footer() {
        let mut app = make_app(3, 0);
        app.mode = ViewMode::PatchDetail;
        let buf = render_app(&mut app);
        assert_buffer_contains(&buf, "Esc:back");
        assert_buffer_contains(&buf, "[/]:revision");
        // "d:interdiff" may be truncated at 80 cols, check prefix
        assert_buffer_contains(&buf, "d:inter");
    }

    #[test]
    fn test_render_patch_detail_no_patch_loaded() {
        let mut app = make_app(3, 0);
        app.mode = ViewMode::PatchDetail;
        app.current_patch = None;

        let buf = render_app(&mut app);
        assert_buffer_contains(&buf, "No patch loaded");
    }

    #[test]
    fn test_patch_detail_ctrl_c_quits() {
        let mut app = test_app();
        app.mode = ViewMode::PatchDetail;
        let result = app.handle_key(
            crossterm::event::KeyCode::Char('c'),
            crossterm::event::KeyModifiers::CONTROL,
        );
        assert_eq!(result, KeyAction::Quit);
    }

    #[test]
    fn test_full_patch_detail_flow() {
        let mut app = test_app();
        app.patches[0].fixes = Some("i1".into());
        app.pane = Pane::Detail;
        app.list_state.select(Some(0));

        // Press p to open patch detail
        let action = app.handle_key(
            crossterm::event::KeyCode::Char('p'),
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(action, KeyAction::OpenPatchDetail);

        // Simulate what events.rs does
        app.current_patch = Some(make_patch_with_revisions());
        app.patch_revision_idx = 1;
        app.patch_diff = "diff content".into();
        app.mode = ViewMode::PatchDetail;

        // Navigate revisions
        app.handle_key(
            crossterm::event::KeyCode::Char('['),
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(app.patch_revision_idx, 0);

        // Toggle interdiff
        app.handle_key(
            crossterm::event::KeyCode::Char('d'),
            crossterm::event::KeyModifiers::empty(),
        );
        assert!(app.patch_interdiff_mode);

        // Scroll
        app.handle_key(
            crossterm::event::KeyCode::Char('j'),
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(app.patch_scroll, 1);

        // Escape back
        app.handle_key(
            crossterm::event::KeyCode::Esc,
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(app.mode, ViewMode::Details);
        assert!(app.current_patch.is_none());
    }

    #[test]
    fn test_linked_patch_for_selected() {
        let mut app = test_app();
        app.patches[0].fixes = Some("i1".into());
        app.list_state.select(Some(0));

        let patch = app.linked_patch_for_selected();
        assert!(patch.is_some());
        assert_eq!(patch.unwrap().title, "Login fix patch");

        // Second issue has no linked patch
        app.list_state.select(Some(1));
        assert!(app.linked_patch_for_selected().is_none());
    }
}