a73x

4d747267

Merge commit 'd6232f3' into 010-email-patch-import

a73x   2026-03-21 09:57

# Conflicts:
#	src/tui.rs

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 0889949..8909477 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 c3c5d8b..0d0840d 100644
--- a/src/tui.rs
+++ b/src/tui.rs
@@ -5,31 +5,42 @@ use std::time::Duration;
use crossterm::event::{self, Event, KeyCode, KeyModifiers};
use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen};
use crossterm::ExecutableCommand;
use git2::Repository;
use git2::{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::issue as issue_mod;
use crate::patch as patch_mod;
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,
}

#[derive(Debug, PartialEq, Clone, Copy)]
@@ -80,6 +91,8 @@ struct App {
    input_buf: String,
    create_title: String,
    status_msg: Option<String>,
    event_history: Vec<(Oid, crate::event::Event)>,
    event_list_state: ListState,
}

impl App {
@@ -103,6 +116,8 @@ impl App {
            input_buf: String::new(),
            create_title: String::new(),
            status_msg: None,
            event_history: Vec::new(),
            event_list_state: ListState::default(),
        }
    }

@@ -248,6 +263,183 @@ impl App {
        }
    }

    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.status_filter = self.status_filter.next();
                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;
@@ -268,6 +460,115 @@ 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",
        Action::IssueEdit { .. } => "Issue Edit",
        Action::IssueLabel { .. } => "Issue Label",
        Action::IssueUnlabel { .. } => "Issue Unlabel",
        Action::IssueAssign { .. } => "Issue Assign",
        Action::IssueUnassign { .. } => "Issue Unassign",
    }
}

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::IssueEdit { title, body } => {
            if let Some(t) = title {
                detail.push_str(&format!("\nNew Title: {}\n", t));
            }
            if let Some(b) = body {
                if !b.is_empty() {
                    detail.push_str(&format!("\nNew Body: {}\n", b));
                }
            }
        }
        Action::IssueLabel { label } => {
            detail.push_str(&format!("\nLabel: {}\n", label));
        }
        Action::IssueUnlabel { label } => {
            detail.push_str(&format!("\nRemoved Label: {}\n", label));
        }
        Action::IssueAssign { assignee } => {
            detail.push_str(&format!("\nAssignee: {}\n", assignee));
        }
        Action::IssueUnassign { assignee } => {
            detail.push_str(&format!("\nRemoved Assignee: {}\n", assignee));
        }
        Action::IssueReopen | Action::PatchMerge | Action::Merge => {}
    }

    detail
}

pub fn run(repo: &Repository) -> Result<(), Error> {
    let issues = state::list_issues(repo)?;
    let patches = state::list_patches(repo)?;
@@ -423,123 +724,112 @@ fn run_loop(
                    InputMode::Normal => {}
                }

                match key.code {
                    KeyCode::Char('q') | KeyCode::Esc => return Ok(()),
                    KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
                        return Ok(())
                    }
                    KeyCode::Char('/') => {
                        app.input_mode = InputMode::Search;
                        app.search_query.clear();
                    }
                    KeyCode::Char('n') => {
                        if app.tab != Tab::Issues {
                            app.switch_tab(Tab::Issues);
                // Handle keys that need repo access or are run_loop-specific
                // before delegating to handle_key
                if app.mode == ViewMode::Details || app.mode == ViewMode::Diff {
                    match key.code {
                        KeyCode::Char('/') => {
                            app.input_mode = InputMode::Search;
                            app.search_query.clear();
                            continue;
                        }
                        app.input_mode = InputMode::CreateTitle;
                        app.input_buf.clear();
                        app.create_title.clear();
                    }
                    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('n') => {
                            if app.tab != Tab::Issues {
                                app.switch_tab(Tab::Issues);
                            }
                            app.input_mode = InputMode::CreateTitle;
                            app.input_buf.clear();
                            app.create_title.clear();
                            continue;
                        }
                    }
                    KeyCode::Char('k') | KeyCode::Up => {
                        if app.pane == Pane::ItemList {
                            app.move_selection(-1);
                        } else {
                            app.scroll = app.scroll.saturating_sub(1);
                        KeyCode::Char('g') => {
                            if !app.follow_link() {
                                app.status_msg = Some("No linked item to follow".to_string());
                            }
                            continue;
                        }
                    }
                    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,
                        KeyCode::Char('o') => {
                            // Check out the relevant commit for local browsing
                            let checkout_target = match app.tab {
                                Tab::Patches => {
                                    let visible = app.visible_patches();
                                    app.list_state
                                        .selected()
                                        .and_then(|idx| visible.get(idx))
                                        .map(|p| p.head_commit.clone())
                                }
                                Tab::Issues => {
                                    // Find linked patch's head commit, or fall back to closing commit
                                    let visible = app.visible_issues();
                                    app.list_state
                                        .selected()
                                        .and_then(|idx| visible.get(idx))
                                        .and_then(|issue| {
                                            // Try linked patch first
                                            app.patches
                                                .iter()
                                                .find(|p| p.fixes.as_deref() == Some(&issue.id))
                                                .map(|p| p.head_commit.clone())
                                                // Fall back to closing commit
                                                .or_else(|| {
                                                    issue.closed_by.map(|oid| oid.to_string())
                                                })
                                        })
                                }
                            };
                            app.scroll = 0;
                        }
                    }
                    KeyCode::Char('a') => {
                        app.status_filter = app.status_filter.next();
                        let count = app.visible_count();
                        app.list_state
                            .select(if count > 0 { Some(0) } else { None });
                    }
                    KeyCode::Char('g') => {
                        if !app.follow_link() {
                            app.status_msg = Some("No linked item to follow".to_string());
                            if let Some(head) = checkout_target {
                                // Exit TUI, checkout, and return
                                terminal::disable_raw_mode()?;
                                stdout().execute(LeaveAlternateScreen)?;
                                let status = std::process::Command::new("git")
                                    .args(["checkout", &head])
                                    .status();
                                match status {
                                    Ok(s) if s.success() => {
                                        println!("Checked out commit: {:.8}", head);
                                        println!("Use 'git checkout -' to return.");
                                    }
                                    Ok(s) => {
                                        eprintln!("git checkout exited with {}", s);
                                    }
                                    Err(e) => {
                                        eprintln!("Failed to run git checkout: {}", e);
                                    }
                                }
                                return Ok(());
                            } else {
                                app.status_msg =
                                    Some("No linked patch to check out".to_string());
                            }
                            continue;
                        }
                        _ => {}
                    }
                    KeyCode::Char('o') => {
                        // Check out the relevant commit for local browsing
                        let checkout_target = match app.tab {
                            Tab::Patches => {
                                let visible = app.visible_patches();
                                app.list_state
                                    .selected()
                                    .and_then(|idx| visible.get(idx))
                                    .map(|p| p.head_commit.clone())
                            }
                            Tab::Issues => {
                                // Find linked patch's head commit, or fall back to closing commit
                                let visible = app.visible_issues();
                                app.list_state
                                    .selected()
                                    .and_then(|idx| visible.get(idx))
                                    .and_then(|issue| {
                                        // Try linked patch first
                                        app.patches
                                            .iter()
                                            .find(|p| p.fixes.as_deref() == Some(&issue.id))
                                            .map(|p| p.head_commit.clone())
                                            // Fall back to closing commit
                                            .or_else(|| issue.closed_by.map(|oid| oid.to_string()))
                                    })
                            }
                        };
                        if let Some(head) = checkout_target {
                            // Exit TUI, checkout, and return
                            terminal::disable_raw_mode()?;
                            stdout().execute(LeaveAlternateScreen)?;
                            let status = std::process::Command::new("git")
                                .args(["checkout", &head])
                                .status();
                            match status {
                                Ok(s) if s.success() => {
                                    println!("Checked out commit: {:.8}", head);
                                    println!("Use 'git checkout -' to return.");
                                }
                                Ok(s) => {
                                    eprintln!("git checkout exited with {}", s);
                }

                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) => {
                                    eprintln!("Failed to run git checkout: {}", e);
                                    app.status_msg =
                                        Some(format!("Error loading events: {}", e));
                                }
                            }
                            return Ok(());
                        } else {
                            app.status_msg =
                                Some("No linked patch to check out".to_string());
                        }
                    }
                    KeyCode::Char('r') => {
                        app.reload(repo);
                    }
                    _ => {}
                    KeyAction::Continue => {}
                }
            }
        }
@@ -669,17 +959,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 {
@@ -705,6 +1053,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 matches for current filter."),
            }
@@ -1166,33 +1515,39 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
        InputMode::Normal => {}
    }

    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(format!(" {}", msg)).style(Style::default().bg(Color::Yellow).fg(Color::Black));
        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 = match app.status_filter {
        StatusFilter::Open => "a:show all",
        StatusFilter::All => "a:closed",
        StatusFilter::Closed => "a:open only",
    };
    let text = if let Some(ref msg) = app.status_msg {
        format!(" {}", msg)
    } else {
        format!(
            " 1:issues  2:patches  j/k:navigate  Tab:pane  {}{}  /:search  n:new issue  g:follow  o:checkout  r:refresh  q:quit",
            filter_hint, mode_hint
        )
    };
    let style = if app.status_msg.is_some() {
        Style::default().bg(Color::Yellow).fg(Color::Black)
    } else {
        Style::default().bg(Color::DarkGray).fg(Color::White)
    };
    let para = Paragraph::new(text).style(style);
    let text = format!(
        " 1:issues  2:patches  j/k:navigate  Tab:pane  {}{}  /:search  n:new issue  g:follow  o:checkout  r:refresh  q:quit",
        filter_hint, mode_hint
    );
    let para = Paragraph::new(text).style(Style::default().bg(Color::DarkGray).fg(Color::White));
    frame.render_widget(para, area);
}

@@ -1396,4 +1751,734 @@ mod tests {

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

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

    use crate::event::ReviewVerdict;
    use ratatui::backend::TestBackend;
    use ratatui::buffer::Buffer;

    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(),
                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),
                fixes: None,
                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 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)> {
        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()),
                    },
                },
            ),
        ]
    }

    // ── 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(),
            fixes: 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(),
        };
        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");
    }

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

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

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

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

    // ── 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();
        app.mode = ViewMode::CommitList;

        app.handle_key(KeyCode::Enter, 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(KeyCode::Esc, 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(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);
    }

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

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

    #[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.list_state.select(Some(0));
        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(0));

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

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

    // ── 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, "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.status_filter = StatusFilter::All;
        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 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)).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(KeyCode::Char('c'), 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(KeyCode::Char('j'), KeyModifiers::empty());
        assert_eq!(app.event_list_state.selected(), Some(1));

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

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

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

        app.handle_key(KeyCode::Esc, 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");
    }
}