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