a73x

d6232f34

Add commit browser, editor module, and dashboard tests

a73x   2026-03-21 09:43

- Commit browser: browse event history for issues/patches with 'c' key
- Editor module: open file at inline comment location with $EDITOR
- Dashboard tests: 35 unit/render/integration tests, handle_key() refactor

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

diff --git a/src/editor.rs b/src/editor.rs
new file mode 100644
index 0000000..1c499fc
--- /dev/null
+++ b/src/editor.rs
@@ -0,0 +1,275 @@
use std::process::Command;

use crate::error::Error;

/// Resolve the user's preferred editor by checking $VISUAL, then $EDITOR.
/// Returns `None` if neither is set or both are empty.
pub fn resolve_editor() -> Option<String> {
    resolve_editor_from(
        std::env::var("VISUAL").ok().as_deref(),
        std::env::var("EDITOR").ok().as_deref(),
    )
}

/// Inner helper: resolve editor from explicit values (testable without env mutation).
fn resolve_editor_from(visual: Option<&str>, editor: Option<&str>) -> Option<String> {
    for val in [visual, editor].into_iter().flatten() {
        let trimmed = val.trim();
        if !trimmed.is_empty() {
            return Some(trimmed.to_string());
        }
    }
    None
}

/// Launch the editor at a specific file and line number.
///
/// The editor string is split on whitespace to support editors like `code --wait`.
/// The command is invoked as: `<editor...> +{line} {file}`.
pub fn open_editor_at(file: &str, line: u32) -> Result<(), Error> {
    let editor_str = resolve_editor().ok_or_else(|| {
        Error::Cmd("No editor configured. Set $EDITOR or $VISUAL.".to_string())
    })?;

    open_editor_at_with(file, line, &editor_str)
}

/// Inner helper: launch an editor command at a specific file and line.
/// Separated from `open_editor_at` so tests can pass an explicit editor string
/// without mutating environment variables.
fn open_editor_at_with(file: &str, line: u32, editor_str: &str) -> Result<(), Error> {
    if !std::path::Path::new(file).exists() {
        return Err(Error::Cmd(format!("File not found: {}", file)));
    }

    let parts: Vec<&str> = editor_str.split_whitespace().collect();
    if parts.is_empty() {
        return Err(Error::Cmd("Editor command is empty".to_string()));
    }

    let program = parts[0];
    let extra_args = &parts[1..];

    let status = Command::new(program)
        .args(extra_args)
        .arg(format!("+{}", line))
        .arg(file)
        .status()
        .map_err(|e| Error::Cmd(format!("Failed to launch editor '{}': {}", program, e)))?;

    if !status.success() {
        let code = status.code().unwrap_or(-1);
        return Err(Error::Cmd(format!("Editor exited with status: {}", code)));
    }

    Ok(())
}

/// Given a list of comment positions `(file, line, rendered_row)` and the
/// current scroll position, find the comment whose rendered row is closest to
/// and at or above the scroll position. Returns `(file, line)` if found.
///
/// The `rendered_position` (third tuple element) is the row index within the
/// rendered diff text where this inline comment appears.
pub fn find_comment_at_scroll(
    comments: &[(String, u32, usize)],
    scroll_pos: u16,
) -> Option<(String, u32)> {
    let scroll = scroll_pos as usize;
    let mut best: Option<&(String, u32, usize)> = None;

    for entry in comments {
        let rendered = entry.2;
        if rendered <= scroll {
            match best {
                Some(prev) if rendered > prev.2 => best = Some(entry),
                None => best = Some(entry),
                _ => {}
            }
        }
    }

    best.map(|e| (e.0.clone(), e.1))
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::io::Write;

    // ---- resolve_editor tests (pure, no env mutation) ----

    #[test]
    fn test_resolve_editor_visual_takes_precedence() {
        let result = resolve_editor_from(Some("nvim"), Some("vi"));
        assert_eq!(result, Some("nvim".to_string()));
    }

    #[test]
    fn test_resolve_editor_falls_back_to_editor() {
        let result = resolve_editor_from(None, Some("nano"));
        assert_eq!(result, Some("nano".to_string()));
    }

    #[test]
    fn test_resolve_editor_none_when_unset() {
        let result = resolve_editor_from(None, None);
        assert_eq!(result, None);
    }

    #[test]
    fn test_resolve_editor_skips_empty_visual() {
        let result = resolve_editor_from(Some("  "), Some("vim"));
        assert_eq!(result, Some("vim".to_string()));
    }

    #[test]
    fn test_resolve_editor_both_empty() {
        let result = resolve_editor_from(Some(""), Some(""));
        assert_eq!(result, None);
    }

    #[test]
    fn test_resolve_editor_trims_whitespace() {
        let result = resolve_editor_from(None, Some("  vim  "));
        assert_eq!(result, Some("vim".to_string()));
    }

    // ---- find_comment_at_scroll tests ----

    #[test]
    fn test_find_comment_at_scroll_empty() {
        let comments: Vec<(String, u32, usize)> = vec![];
        assert_eq!(find_comment_at_scroll(&comments, 10), None);
    }

    #[test]
    fn test_find_comment_at_scroll_exact_match() {
        let comments = vec![
            ("src/main.rs".to_string(), 42, 10),
            ("src/lib.rs".to_string(), 15, 20),
        ];
        let result = find_comment_at_scroll(&comments, 10);
        assert_eq!(result, Some(("src/main.rs".to_string(), 42)));
    }

    #[test]
    fn test_find_comment_at_scroll_picks_closest_above() {
        let comments = vec![
            ("a.rs".to_string(), 1, 5),
            ("b.rs".to_string(), 2, 15),
            ("c.rs".to_string(), 3, 25),
        ];
        // Scroll is at 20, closest at-or-above is rendered_pos=15
        let result = find_comment_at_scroll(&comments, 20);
        assert_eq!(result, Some(("b.rs".to_string(), 2)));
    }

    #[test]
    fn test_find_comment_at_scroll_all_below() {
        let comments = vec![
            ("a.rs".to_string(), 1, 30),
            ("b.rs".to_string(), 2, 40),
        ];
        // Scroll at 10, all comments are below
        let result = find_comment_at_scroll(&comments, 10);
        assert_eq!(result, None);
    }

    #[test]
    fn test_find_comment_at_scroll_first_wins_on_tie() {
        // Two comments at same rendered position -- first in list wins
        // because our > comparison doesn't replace when equal.
        let comments = vec![
            ("a.rs".to_string(), 1, 10),
            ("b.rs".to_string(), 2, 10),
        ];
        let result = find_comment_at_scroll(&comments, 10);
        assert_eq!(result, Some(("a.rs".to_string(), 1)));
    }

    #[test]
    fn test_find_comment_at_scroll_scroll_at_zero() {
        let comments = vec![
            ("a.rs".to_string(), 1, 0),
            ("b.rs".to_string(), 2, 5),
        ];
        let result = find_comment_at_scroll(&comments, 0);
        assert_eq!(result, Some(("a.rs".to_string(), 1)));
    }

    #[test]
    fn test_find_comment_at_scroll_single_comment_above() {
        let comments = vec![("only.rs".to_string(), 99, 3)];
        let result = find_comment_at_scroll(&comments, 100);
        assert_eq!(result, Some(("only.rs".to_string(), 99)));
    }

    // ---- open_editor_at tests (using inner helper, no env mutation) ----

    #[test]
    fn test_open_editor_at_success_with_true() {
        let mut tmp = tempfile::NamedTempFile::new().unwrap();
        writeln!(tmp, "hello").unwrap();
        let path = tmp.path().to_str().unwrap().to_string();

        let result = open_editor_at_with(&path, 1, "true");
        assert!(result.is_ok(), "Expected Ok, got {:?}", result);
    }

    #[test]
    fn test_open_editor_at_file_not_found() {
        let result =
            open_editor_at_with("/tmp/nonexistent_file_for_test_abc123xyz.txt", 1, "true");
        assert!(result.is_err());
        let err_msg = format!("{}", result.unwrap_err());
        assert!(
            err_msg.contains("File not found"),
            "Unexpected error: {}",
            err_msg
        );
    }

    #[test]
    fn test_open_editor_at_nonzero_exit() {
        let mut tmp = tempfile::NamedTempFile::new().unwrap();
        writeln!(tmp, "hello").unwrap();
        let path = tmp.path().to_str().unwrap().to_string();

        let result = open_editor_at_with(&path, 1, "false");
        assert!(result.is_err());
        let err_msg = format!("{}", result.unwrap_err());
        assert!(
            err_msg.contains("Editor exited with status"),
            "Unexpected error: {}",
            err_msg
        );
    }

    #[test]
    fn test_open_editor_at_with_args_in_editor_string() {
        // `true` ignores all arguments, so "true --wait" should succeed
        let mut tmp = tempfile::NamedTempFile::new().unwrap();
        writeln!(tmp, "hello").unwrap();
        let path = tmp.path().to_str().unwrap().to_string();

        let result = open_editor_at_with(&path, 42, "true --wait");
        assert!(result.is_ok(), "Expected Ok, got {:?}", result);
    }

    #[test]
    fn test_open_editor_at_bad_command() {
        let mut tmp = tempfile::NamedTempFile::new().unwrap();
        writeln!(tmp, "hello").unwrap();
        let path = tmp.path().to_str().unwrap().to_string();

        let result = open_editor_at_with(&path, 1, "nonexistent_editor_binary_xyz");
        assert!(result.is_err());
        let err_msg = format!("{}", result.unwrap_err());
        assert!(
            err_msg.contains("Failed to launch editor"),
            "Unexpected error: {}",
            err_msg
        );
    }
}
diff --git a/src/lib.rs b/src/lib.rs
index 46cbe62..b8d548c 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1,5 +1,6 @@
pub mod cli;
pub mod dag;
pub mod editor;
pub mod error;
pub mod event;
pub mod identity;
diff --git a/src/tui.rs b/src/tui.rs
index 05d05dd..daa2e28 100644
--- a/src/tui.rs
+++ b/src/tui.rs
@@ -5,29 +5,40 @@ use std::time::Duration;
use crossterm::event::{self, Event, KeyCode, KeyModifiers};
use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen};
use crossterm::ExecutableCommand;
use git2::{DiffFormat, Repository};
use git2::{DiffFormat, Oid, Repository};
use ratatui::prelude::*;
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Tabs, Wrap};

use crate::error::Error;
use crate::event::Action;
use crate::state::{self, IssueState, IssueStatus, PatchState, PatchStatus};

#[derive(PartialEq)]
#[derive(Debug, PartialEq)]
enum Pane {
    ItemList,
    Detail,
}

#[derive(PartialEq, Clone, Copy)]
#[derive(Debug, PartialEq, Clone, Copy)]
enum Tab {
    Issues,
    Patches,
}

#[derive(PartialEq)]
#[derive(Debug, PartialEq)]
enum ViewMode {
    Details,
    Diff,
    CommitList,
    CommitDetail,
}

#[derive(Debug, PartialEq)]
enum KeyAction {
    Continue,
    Quit,
    Reload,
    OpenCommitBrowser,
}

struct App {
@@ -40,6 +51,9 @@ struct App {
    pane: Pane,
    mode: ViewMode,
    show_all: bool,
    event_history: Vec<(Oid, crate::event::Event)>,
    event_list_state: ListState,
    status_msg: Option<String>,
}

impl App {
@@ -58,6 +72,9 @@ impl App {
            pane: Pane::ItemList,
            mode: ViewMode::Details,
            show_all: false,
            event_history: Vec::new(),
            event_list_state: ListState::default(),
            status_msg: None,
        }
    }

@@ -131,6 +148,183 @@ impl App {
        self.pane = Pane::ItemList;
    }

    fn handle_key(&mut self, code: KeyCode, modifiers: KeyModifiers) -> KeyAction {
        // Handle CommitDetail mode first
        if self.mode == ViewMode::CommitDetail {
            match code {
                KeyCode::Esc => {
                    self.mode = ViewMode::CommitList;
                    self.scroll = 0;
                    return KeyAction::Continue;
                }
                KeyCode::Char('q') => return KeyAction::Quit,
                KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => {
                    return KeyAction::Quit;
                }
                KeyCode::Char('j') | KeyCode::Down => {
                    self.scroll = self.scroll.saturating_add(1);
                    return KeyAction::Continue;
                }
                KeyCode::Char('k') | KeyCode::Up => {
                    self.scroll = self.scroll.saturating_sub(1);
                    return KeyAction::Continue;
                }
                KeyCode::PageDown => {
                    self.scroll = self.scroll.saturating_add(20);
                    return KeyAction::Continue;
                }
                KeyCode::PageUp => {
                    self.scroll = self.scroll.saturating_sub(20);
                    return KeyAction::Continue;
                }
                _ => return KeyAction::Continue,
            }
        }

        // Handle CommitList mode
        if self.mode == ViewMode::CommitList {
            match code {
                KeyCode::Esc => {
                    self.event_history.clear();
                    self.event_list_state = ListState::default();
                    self.mode = ViewMode::Details;
                    self.scroll = 0;
                    return KeyAction::Continue;
                }
                KeyCode::Char('q') => return KeyAction::Quit,
                KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => {
                    return KeyAction::Quit;
                }
                KeyCode::Char('j') | KeyCode::Down => {
                    let len = self.event_history.len();
                    if len > 0 {
                        let current = self.event_list_state.selected().unwrap_or(0);
                        let new = (current + 1).min(len - 1);
                        self.event_list_state.select(Some(new));
                    }
                    return KeyAction::Continue;
                }
                KeyCode::Char('k') | KeyCode::Up => {
                    if !self.event_history.is_empty() {
                        let current = self.event_list_state.selected().unwrap_or(0);
                        let new = current.saturating_sub(1);
                        self.event_list_state.select(Some(new));
                    }
                    return KeyAction::Continue;
                }
                KeyCode::Enter => {
                    if self.event_list_state.selected().is_some() {
                        self.mode = ViewMode::CommitDetail;
                        self.scroll = 0;
                    }
                    return KeyAction::Continue;
                }
                _ => return KeyAction::Continue,
            }
        }

        // Normal Details/Diff mode handling
        match code {
            KeyCode::Char('q') | KeyCode::Esc => KeyAction::Quit,
            KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => KeyAction::Quit,
            KeyCode::Char('c') => {
                // Open commit browser: only when in detail pane with an item selected
                if self.pane == Pane::Detail && self.list_state.selected().is_some() {
                    KeyAction::OpenCommitBrowser
                } else {
                    KeyAction::Continue
                }
            }
            KeyCode::Char('1') => {
                self.switch_tab(Tab::Issues);
                KeyAction::Continue
            }
            KeyCode::Char('2') => {
                self.switch_tab(Tab::Patches);
                KeyAction::Continue
            }
            KeyCode::Char('j') | KeyCode::Down => {
                if self.pane == Pane::ItemList {
                    self.move_selection(1);
                } else {
                    self.scroll = self.scroll.saturating_add(1);
                }
                KeyAction::Continue
            }
            KeyCode::Char('k') | KeyCode::Up => {
                if self.pane == Pane::ItemList {
                    self.move_selection(-1);
                } else {
                    self.scroll = self.scroll.saturating_sub(1);
                }
                KeyAction::Continue
            }
            KeyCode::PageDown => {
                self.scroll = self.scroll.saturating_add(20);
                KeyAction::Continue
            }
            KeyCode::PageUp => {
                self.scroll = self.scroll.saturating_sub(20);
                KeyAction::Continue
            }
            KeyCode::Tab | KeyCode::Enter => {
                self.pane = match self.pane {
                    Pane::ItemList => Pane::Detail,
                    Pane::Detail => Pane::ItemList,
                };
                KeyAction::Continue
            }
            KeyCode::Char('d') => {
                if self.tab == Tab::Patches {
                    match self.mode {
                        ViewMode::Details => {
                            self.mode = ViewMode::Diff;
                            self.scroll = 0;
                        }
                        ViewMode::Diff => {
                            self.mode = ViewMode::Details;
                            self.scroll = 0;
                        }
                        _ => {}
                    }
                }
                KeyAction::Continue
            }
            KeyCode::Char('a') => {
                self.show_all = !self.show_all;
                let count = self.visible_count();
                self.list_state
                    .select(if count > 0 { Some(0) } else { None });
                KeyAction::Continue
            }
            KeyCode::Char('r') => KeyAction::Reload,
            _ => KeyAction::Continue,
        }
    }

    fn selected_item_id(&self) -> Option<String> {
        let idx = self.list_state.selected()?;
        match self.tab {
            Tab::Issues => {
                let visible = self.visible_issues();
                visible.get(idx).map(|i| i.id.clone())
            }
            Tab::Patches => {
                let visible = self.visible_patches();
                visible.get(idx).map(|p| p.id.clone())
            }
        }
    }

    fn selected_ref_name(&self) -> Option<String> {
        let id = self.selected_item_id()?;
        let prefix = match self.tab {
            Tab::Issues => "refs/collab/issues",
            Tab::Patches => "refs/collab/patches",
        };
        Some(format!("{}/{}", prefix, id))
    }

    fn reload(&mut self, repo: &Repository) {
        if let Ok(issues) = state::list_issues(repo) {
            self.issues = issues;
@@ -151,6 +345,87 @@ impl App {
    }
}

fn action_type_label(action: &Action) -> &str {
    match action {
        Action::IssueOpen { .. } => "Issue Open",
        Action::IssueComment { .. } => "Issue Comment",
        Action::IssueClose { .. } => "Issue Close",
        Action::IssueReopen => "Issue Reopen",
        Action::PatchCreate { .. } => "Patch Create",
        Action::PatchRevise { .. } => "Patch Revise",
        Action::PatchReview { .. } => "Patch Review",
        Action::PatchComment { .. } => "Patch Comment",
        Action::PatchInlineComment { .. } => "Inline Comment",
        Action::PatchClose { .. } => "Patch Close",
        Action::PatchMerge => "Patch Merge",
        Action::Merge => "Merge",
    }
}

fn format_event_detail(oid: &Oid, event: &crate::event::Event) -> String {
    let short_oid = &oid.to_string()[..7];
    let action_label = action_type_label(&event.action);

    let mut detail = format!(
        "Commit:  {}\nAuthor:  {} <{}>\nDate:    {}\nType:    {}\n",
        short_oid, event.author.name, event.author.email, event.timestamp, action_label,
    );

    // Action-specific payload
    match &event.action {
        Action::IssueOpen { title, body } => {
            detail.push_str(&format!("\nTitle: {}\n", title));
            if !body.is_empty() {
                detail.push_str(&format!("\n{}\n", body));
            }
        }
        Action::IssueComment { body } | Action::PatchComment { body } => {
            detail.push_str(&format!("\n{}\n", body));
        }
        Action::IssueClose { reason } | Action::PatchClose { reason } => {
            if let Some(r) = reason {
                detail.push_str(&format!("\nReason: {}\n", r));
            }
        }
        Action::PatchCreate {
            title,
            body,
            base_ref,
            head_commit,
        } => {
            detail.push_str(&format!("\nTitle: {}\n", title));
            detail.push_str(&format!("Base:  {}\n", base_ref));
            detail.push_str(&format!("Head:  {}\n", head_commit));
            if !body.is_empty() {
                detail.push_str(&format!("\n{}\n", body));
            }
        }
        Action::PatchRevise { body, head_commit } => {
            detail.push_str(&format!("\nHead: {}\n", head_commit));
            if let Some(b) = body {
                if !b.is_empty() {
                    detail.push_str(&format!("\n{}\n", b));
                }
            }
        }
        Action::PatchReview { verdict, body } => {
            detail.push_str(&format!("\nVerdict: {:?}\n", verdict));
            if !body.is_empty() {
                detail.push_str(&format!("\n{}\n", body));
            }
        }
        Action::PatchInlineComment { file, line, body } => {
            detail.push_str(&format!("\nFile: {}:{}\n", file, line));
            if !body.is_empty() {
                detail.push_str(&format!("\n{}\n", body));
            }
        }
        Action::IssueReopen | Action::PatchMerge | Action::Merge => {}
    }

    detail
}

fn generate_diff(repo: &Repository, patch: &PatchState) -> String {
    let result = (|| -> Result<String, Error> {
        let head_obj = repo
@@ -255,54 +530,29 @@ fn run_loop(

        if event::poll(Duration::from_millis(100))? {
            if let Event::Key(key) = event::read()? {
                match key.code {
                    KeyCode::Char('q') | KeyCode::Esc => return Ok(()),
                    KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
                        return Ok(())
                    }
                    KeyCode::Char('1') => app.switch_tab(Tab::Issues),
                    KeyCode::Char('2') => app.switch_tab(Tab::Patches),
                    KeyCode::Char('j') | KeyCode::Down => {
                        if app.pane == Pane::ItemList {
                            app.move_selection(1);
                        } else {
                            app.scroll = app.scroll.saturating_add(1);
                        }
                    }
                    KeyCode::Char('k') | KeyCode::Up => {
                        if app.pane == Pane::ItemList {
                            app.move_selection(-1);
                        } else {
                            app.scroll = app.scroll.saturating_sub(1);
                match app.handle_key(key.code, key.modifiers) {
                    KeyAction::Quit => return Ok(()),
                    KeyAction::Reload => app.reload(repo),
                    KeyAction::OpenCommitBrowser => {
                        if let Some(ref_name) = app.selected_ref_name() {
                            match crate::dag::walk_events(repo, &ref_name) {
                                Ok(events) => {
                                    app.event_history = events;
                                    app.event_list_state = ListState::default();
                                    if !app.event_history.is_empty() {
                                        app.event_list_state.select(Some(0));
                                    }
                                    app.mode = ViewMode::CommitList;
                                    app.scroll = 0;
                                }
                                Err(e) => {
                                    app.status_msg =
                                        Some(format!("Error loading events: {}", e));
                                }
                            }
                        }
                    }
                    KeyCode::PageDown => app.scroll = app.scroll.saturating_add(20),
                    KeyCode::PageUp => app.scroll = app.scroll.saturating_sub(20),
                    KeyCode::Tab | KeyCode::Enter => {
                        app.pane = match app.pane {
                            Pane::ItemList => Pane::Detail,
                            Pane::Detail => Pane::ItemList,
                        };
                    }
                    KeyCode::Char('d') => {
                        if app.tab == Tab::Patches {
                            app.mode = match app.mode {
                                ViewMode::Details => ViewMode::Diff,
                                ViewMode::Diff => ViewMode::Details,
                            };
                            app.scroll = 0;
                        }
                    }
                    KeyCode::Char('a') => {
                        app.show_all = !app.show_all;
                        let count = app.visible_count();
                        app.list_state
                            .select(if count > 0 { Some(0) } else { None });
                    }
                    KeyCode::Char('r') => {
                        app.reload(repo);
                    }
                    _ => {}
                    KeyAction::Continue => {}
                }
            }
        }
@@ -440,17 +690,75 @@ fn render_list(frame: &mut Frame, app: &mut App, area: Rect) {
    }
}

fn render_detail(frame: &mut Frame, app: &App, area: Rect) {
fn render_detail(frame: &mut Frame, app: &mut App, area: Rect) {
    let border_style = if app.pane == Pane::Detail {
        Style::default().fg(Color::Yellow)
    } else {
        Style::default().fg(Color::DarkGray)
    };

    // Handle commit browser modes
    if app.mode == ViewMode::CommitList {
        let items: Vec<ListItem> = app
            .event_history
            .iter()
            .map(|(_oid, evt)| {
                let label = action_type_label(&evt.action);
                ListItem::new(format!(
                    "{} | {} | {}",
                    label, evt.author.name, evt.timestamp
                ))
            })
            .collect();

        let list = List::new(items)
            .block(
                Block::default()
                    .borders(Borders::ALL)
                    .title("Event History")
                    .border_style(border_style),
            )
            .highlight_style(
                Style::default()
                    .bg(Color::DarkGray)
                    .add_modifier(Modifier::BOLD),
            )
            .highlight_symbol("> ");

        frame.render_stateful_widget(list, area, &mut app.event_list_state);
        return;
    }

    if app.mode == ViewMode::CommitDetail {
        let content = if let Some(idx) = app.event_list_state.selected() {
            if let Some((oid, evt)) = app.event_history.get(idx) {
                format_event_detail(oid, evt)
            } else {
                "No event selected.".to_string()
            }
        } else {
            "No event selected.".to_string()
        };

        let block = Block::default()
            .borders(Borders::ALL)
            .title("Event Detail")
            .border_style(border_style);

        let para = Paragraph::new(content)
            .block(block)
            .wrap(Wrap { trim: false })
            .scroll((app.scroll, 0));

        frame.render_widget(para, area);
        return;
    }

    let title = match (&app.tab, &app.mode) {
        (Tab::Issues, _) => "Issue Details",
        (Tab::Patches, ViewMode::Details) => "Patch Details",
        (Tab::Patches, ViewMode::Diff) => "Diff",
        _ => "Details",
    };

    let content: Text = match app.tab {
@@ -476,6 +784,7 @@ fn render_detail(frame: &mut Frame, app: &App, area: Rect) {
                            .unwrap_or("Loading...");
                        colorize_diff(diff_text, &patch.inline_comments)
                    }
                    _ => Text::raw(""),
                },
                None => Text::raw("No patches to display."),
            }
@@ -829,13 +1138,28 @@ fn colorize_diff(diff: &str, inline_comments: &[state::InlineComment]) -> Text<'
}

fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
    let mode_hint = if app.tab == Tab::Patches {
        match app.mode {
            ViewMode::Details => "  d:diff",
            ViewMode::Diff => "  d:details",
    // Show status message if present
    if let Some(ref msg) = app.status_msg {
        let para =
            Paragraph::new(msg.clone()).style(Style::default().bg(Color::Red).fg(Color::White));
        frame.render_widget(para, area);
        return;
    }

    let mode_hint = match app.mode {
        ViewMode::CommitList => "  Esc:back",
        ViewMode::CommitDetail => "  Esc:back  j/k:scroll",
        _ => {
            if app.tab == Tab::Patches {
                match app.mode {
                    ViewMode::Details => "  d:diff  c:events",
                    ViewMode::Diff => "  d:details  c:events",
                    _ => "",
                }
            } else {
                "  c:events"
            }
        }
    } else {
        ""
    };
    let filter_hint = if app.show_all {
        "a:open only"
@@ -849,3 +1173,884 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
    let para = Paragraph::new(text).style(Style::default().bg(Color::DarkGray).fg(Color::White));
    frame.render_widget(para, area);
}

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

    // ── 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
                },
                comments: Vec::new(),
                created_at: "2026-01-01T00:00:00Z".to_string(),
                author: test_author(),
            })
            .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(),
                head_commit: format!("h{:07x}", i),
                comments: Vec::new(),
                inline_comments: Vec::new(),
                reviews: Vec::new(),
                created_at: "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 key(code: KeyCode) -> (KeyCode, KeyModifiers) {
        (code, KeyModifiers::empty())
    }

    fn apply_keys(app: &mut App, keys: &[(KeyCode, KeyModifiers)]) -> Vec<KeyAction> {
        keys.iter()
            .map(|(code, mods)| app.handle_key(*code, *mods))
            .collect()
    }

    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)).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)> {
        // Use a known OID format (40 hex chars)
        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(),
                    },
                },
            ),
            (
                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(),
                    },
                },
            ),
            (
                oid3,
                crate::event::Event {
                    timestamp: "2026-01-03T00:00:00Z".to_string(),
                    author: test_author(),
                    action: Action::IssueClose {
                        reason: Some("fixed".to_string()),
                    },
                },
            ),
        ]
    }

    // ── Phase 1: action_type_label tests ──────────────────────────────────

    #[test]
    fn test_action_type_label_issue_open() {
        let action = Action::IssueOpen {
            title: "t".to_string(),
            body: "b".to_string(),
        };
        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(),
            head_commit: "abc".to_string(),
        };
        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(),
        };
        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(),
        };
        assert_eq!(action_type_label(&action), "Inline Comment");
    }

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

    // ── Phase 1: 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(),
            },
        };
        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()),
            },
        };
        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(),
            },
        };
        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,
        };
        let detail = format_event_detail(&oid, &event);
        assert!(detail.contains("1234567"));
        // Should NOT contain the full 40-char OID in the Commit line
        assert!(detail.contains("Commit:  1234567\n"));
    }

    // ── Phase 2: 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(KeyCode::Char('c'), 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(KeyCode::Char('c'), 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(KeyCode::Char('c'), 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(KeyCode::Char('c'), KeyModifiers::CONTROL);
        assert_eq!(result, KeyAction::Quit);
    }

    // ── Phase 2: 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(KeyCode::Char('j'), 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(KeyCode::Char('k'), 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(); // 3 events
        app.event_list_state.select(Some(2));
        app.mode = ViewMode::CommitList;

        app.handle_key(KeyCode::Down, KeyModifiers::empty());
        assert_eq!(app.event_list_state.selected(), Some(2)); // stays at end
    }

    #[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(KeyCode::Up, KeyModifiers::empty());
        assert_eq!(app.event_list_state.selected(), Some(0)); // stays at top
    }

    #[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(KeyCode::Esc, 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(KeyCode::Char('q'), KeyModifiers::empty());
        assert_eq!(result, KeyAction::Quit);
    }

    // ── Phase 3: 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(KeyCode::Enter, 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(); // no selection
        app.mode = ViewMode::CommitList;

        app.handle_key(KeyCode::Enter, KeyModifiers::empty());
        assert_eq!(app.mode, ViewMode::CommitList); // stays in list
    }

    #[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(KeyCode::Esc, KeyModifiers::empty());
        assert_eq!(result, KeyAction::Continue);
        assert_eq!(app.mode, ViewMode::CommitList);
        assert_eq!(app.scroll, 0);
        // event_history should still be populated
        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(KeyCode::Char('j'), KeyModifiers::empty());
        assert_eq!(app.scroll, 1);
        app.handle_key(KeyCode::Char('j'), KeyModifiers::empty());
        assert_eq!(app.scroll, 2);
        app.handle_key(KeyCode::Char('k'), 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(KeyCode::PageDown, KeyModifiers::empty());
        assert_eq!(app.scroll, 20);
        app.handle_key(KeyCode::PageUp, 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(KeyCode::Char('q'), KeyModifiers::empty());
        assert_eq!(result, KeyAction::Quit);
    }

    // ── Phase 5: Guard tests ──────────────────────────────────────────────

    #[test]
    fn test_c_ignored_in_commit_list_mode() {
        let mut app = make_app(3, 0);
        app.mode = ViewMode::CommitList;
        // 'c' without CONTROL in CommitList should be ignored (falls to _ arm)
        let result = app.handle_key(KeyCode::Char('c'), 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(KeyCode::Char('c'), KeyModifiers::empty());
        assert_eq!(result, KeyAction::Continue);
        assert_eq!(app.mode, ViewMode::CommitDetail);
    }

    // ── Existing tests (from 011-dashboard-testing) ───────────────────────

    #[test]
    fn test_new_app_defaults() {
        let app = make_app(3, 2);
        assert_eq!(app.tab, Tab::Issues);
        assert_eq!(app.list_state.selected(), Some(0));
        assert_eq!(app.scroll, 0);
        assert_eq!(app.pane, Pane::ItemList);
        assert_eq!(app.mode, ViewMode::Details);
        assert!(!app.show_all);
        assert!(app.event_history.is_empty());
        assert_eq!(app.event_list_state.selected(), None);
        assert!(app.status_msg.is_none());
    }

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

    #[test]
    fn test_move_selection_down() {
        let mut app = make_app(5, 0);
        app.show_all = true;
        app.list_state.select(Some(0));
        app.move_selection(1);
        assert_eq!(app.list_state.selected(), Some(1));
    }

    #[test]
    fn test_move_selection_up() {
        let mut app = make_app(5, 0);
        app.show_all = true;
        app.list_state.select(Some(2));
        app.move_selection(-1);
        assert_eq!(app.list_state.selected(), Some(1));
    }

    #[test]
    fn test_move_selection_clamp_bottom() {
        let mut app = make_app(5, 0);
        app.show_all = true;
        app.list_state.select(Some(4));
        app.move_selection(1);
        assert_eq!(app.list_state.selected(), Some(4));
    }

    #[test]
    fn test_move_selection_clamp_top() {
        let mut app = make_app(5, 0);
        app.list_state.select(Some(0));
        app.move_selection(-1);
        assert_eq!(app.list_state.selected(), Some(0));
    }

    #[test]
    fn test_move_selection_empty_list() {
        let mut app = make_app(0, 0);
        app.move_selection(1);
        assert_eq!(app.list_state.selected(), None);
    }

    #[test]
    fn test_switch_tab_issues_to_patches() {
        let mut app = make_app(3, 3);
        app.show_all = true;
        app.list_state.select(Some(2));
        app.scroll = 5;
        app.pane = Pane::Detail;

        app.switch_tab(Tab::Patches);
        assert_eq!(app.tab, Tab::Patches);
        assert_eq!(app.list_state.selected(), Some(0));
        assert_eq!(app.scroll, 0);
        assert_eq!(app.mode, ViewMode::Details);
        assert_eq!(app.pane, Pane::ItemList);
    }

    #[test]
    fn test_switch_tab_same_tab_noop() {
        let mut app = make_app(3, 3);
        app.show_all = true;
        app.list_state.select(Some(2));
        app.scroll = 5;
        app.switch_tab(Tab::Issues);
        assert_eq!(app.list_state.selected(), Some(2));
        assert_eq!(app.scroll, 5);
    }

    #[test]
    fn test_toggle_show_all() {
        let mut app = make_app(4, 0);
        assert_eq!(app.visible_count(), 2);
        app.show_all = true;
        assert_eq!(app.visible_count(), 4);
    }

    #[test]
    fn test_visible_issues_filters_closed() {
        let app = make_app(4, 0);
        let visible = app.visible_issues();
        assert_eq!(visible.len(), 2);
        for issue in &visible {
            assert_eq!(issue.status, IssueStatus::Open);
        }
    }

    #[test]
    fn test_visible_patches_filters_closed() {
        let mut app = make_app(0, 4);
        app.tab = Tab::Patches;
        let visible = app.visible_patches();
        assert_eq!(visible.len(), 2);
        for patch in &visible {
            assert_eq!(patch.status, PatchStatus::Open);
        }
    }

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

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

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

    #[test]
    fn test_handle_key_tab_switch() {
        let mut app = make_app(3, 3);
        app.handle_key(KeyCode::Char('2'), KeyModifiers::empty());
        assert_eq!(app.tab, Tab::Patches);
        app.handle_key(KeyCode::Char('1'), KeyModifiers::empty());
        assert_eq!(app.tab, Tab::Issues);
    }

    #[test]
    fn test_handle_key_diff_toggle_patches() {
        let mut app = make_app(0, 3);
        app.switch_tab(Tab::Patches);
        assert_eq!(app.mode, ViewMode::Details);
        app.handle_key(KeyCode::Char('d'), KeyModifiers::empty());
        assert_eq!(app.mode, ViewMode::Diff);
        app.handle_key(KeyCode::Char('d'), KeyModifiers::empty());
        assert_eq!(app.mode, ViewMode::Details);
    }

    #[test]
    fn test_handle_key_diff_noop_issues() {
        let mut app = make_app(3, 0);
        assert_eq!(app.tab, Tab::Issues);
        assert_eq!(app.mode, ViewMode::Details);
        app.handle_key(KeyCode::Char('d'), KeyModifiers::empty());
        assert_eq!(app.mode, ViewMode::Details);
    }

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

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

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

        app.handle_key(KeyCode::Char('k'), 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(KeyCode::Char('r'), KeyModifiers::empty()),
            KeyAction::Reload
        );
    }

    #[test]
    fn test_handle_key_toggle_show_all() {
        let mut app = make_app(4, 0);
        assert!(!app.show_all);
        app.handle_key(KeyCode::Char('a'), KeyModifiers::empty());
        assert!(app.show_all);
        app.handle_key(KeyCode::Char('a'), KeyModifiers::empty());
        assert!(!app.show_all);
    }

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

    #[test]
    fn test_render_issues_tab() {
        let mut app = make_app(3, 2);
        app.show_all = true;
        let buf = render_app(&mut app);
        assert_buffer_contains(&buf, "1:Issues");
        assert_buffer_contains(&buf, "2:Patches");
        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_patches_tab() {
        let mut app = make_app(2, 3);
        app.show_all = true;
        app.switch_tab(Tab::Patches);
        let buf = render_app(&mut app);
        assert_buffer_contains(&buf, "p0000000");
        assert_buffer_contains(&buf, "p0000001");
        assert_buffer_contains(&buf, "p0000002");
        assert_buffer_contains(&buf, "Patch 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 issues to display.");
    }

    #[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, "r:refresh");
    }

    #[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)).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));

        // Press 'c' to open commit browser
        let action = app.handle_key(KeyCode::Char('c'), KeyModifiers::empty());
        assert_eq!(action, KeyAction::OpenCommitBrowser);

        // Simulate run_loop setting up the event history
        app.event_history = make_test_event_history();
        app.event_list_state.select(Some(0));
        app.mode = ViewMode::CommitList;
        app.scroll = 0;

        // Navigate down in event list
        app.handle_key(KeyCode::Char('j'), KeyModifiers::empty());
        assert_eq!(app.event_list_state.selected(), Some(1));

        // Press Enter to view detail
        app.handle_key(KeyCode::Enter, KeyModifiers::empty());
        assert_eq!(app.mode, ViewMode::CommitDetail);

        // Scroll in detail
        app.handle_key(KeyCode::Char('j'), KeyModifiers::empty());
        assert_eq!(app.scroll, 1);

        // Escape back to list
        app.handle_key(KeyCode::Esc, KeyModifiers::empty());
        assert_eq!(app.mode, ViewMode::CommitList);
        assert_eq!(app.scroll, 0);

        // Escape back to details
        app.handle_key(KeyCode::Esc, KeyModifiers::empty());
        assert_eq!(app.mode, ViewMode::Details);
        assert!(app.event_history.is_empty());
    }

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

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

    #[test]
    fn test_selected_ref_name_patches() {
        let mut app = make_app(0, 3);
        app.switch_tab(Tab::Patches);
        let ref_name = app.selected_ref_name();
        assert_eq!(
            ref_name,
            Some("refs/collab/patches/p0000000".to_string())
        );
    }

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

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