00c285b7
Split tui.rs monolith into submodules for maintainability
a73x 2026-03-21 16:30
Break the 1857-line src/tui.rs into src/tui/ directory with: - mod.rs: public run() entry point and all tests - state.rs: App struct, enums (Pane, ViewMode, KeyAction, StatusFilter, InputMode), and key handling - widgets.rs: all rendering functions (ui, render_list, render_detail, render_footer, etc.) - events.rs: the main event loop (run_loop) with input mode handling Public API unchanged: tui::run(&repo) from lib.rs. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
diff --git a/src/tui.rs b/src/tui.rs deleted file mode 100644 index 90b04f8..0000000 --- a/src/tui.rs +++ /dev/null @@ -1,1857 +0,0 @@ use std::io::{self, stdout}; use std::time::Duration; use crossterm::event::{self, Event, KeyCode, KeyModifiers}; use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen}; use crossterm::ExecutableCommand; use git2::{Oid, Repository}; use ratatui::prelude::*; use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap}; use crate::error::Error; use crate::event::Action; use crate::issue as issue_mod; use crate::state::{self, IssueState, IssueStatus, PatchState, PatchStatus}; #[derive(Debug, PartialEq)] enum Pane { ItemList, Detail, } #[derive(Debug, PartialEq)] enum ViewMode { Details, CommitList, CommitDetail, } #[derive(Debug, PartialEq)] enum KeyAction { Continue, Quit, Reload, OpenCommitBrowser, } #[derive(Debug, PartialEq, Clone, Copy)] enum StatusFilter { Open, Closed, All, } impl StatusFilter { fn next(self) -> Self { match self { StatusFilter::Open => StatusFilter::All, StatusFilter::All => StatusFilter::Closed, StatusFilter::Closed => StatusFilter::Open, } } fn label(self) -> &'static str { match self { StatusFilter::Open => "open", StatusFilter::Closed => "closed", StatusFilter::All => "all", } } } #[derive(Debug, PartialEq)] enum InputMode { Normal, Search, CreateTitle, CreateBody, } struct App { issues: Vec<IssueState>, patches: Vec<PatchState>, list_state: ListState, scroll: u16, pane: Pane, mode: ViewMode, status_filter: StatusFilter, search_query: String, input_mode: InputMode, input_buf: String, create_title: String, status_msg: Option<String>, event_history: Vec<(Oid, crate::event::Event)>, event_list_state: ListState, } impl App { fn new(issues: Vec<IssueState>, patches: Vec<PatchState>) -> Self { let mut list_state = ListState::default(); if !issues.is_empty() { list_state.select(Some(0)); } Self { issues, patches, list_state, scroll: 0, pane: Pane::ItemList, mode: ViewMode::Details, status_filter: StatusFilter::Open, search_query: String::new(), input_mode: InputMode::Normal, input_buf: String::new(), create_title: String::new(), status_msg: None, event_history: Vec::new(), event_list_state: ListState::default(), } } fn matches_search(&self, title: &str) -> bool { if self.search_query.is_empty() { return true; } title .to_lowercase() .contains(&self.search_query.to_lowercase()) } fn visible_issues(&self) -> Vec<&IssueState> { self.issues .iter() .filter(|i| match self.status_filter { StatusFilter::Open => i.status == IssueStatus::Open, StatusFilter::Closed => i.status == IssueStatus::Closed, StatusFilter::All => true, }) .filter(|i| self.matches_search(&i.title)) .collect() } fn visible_count(&self) -> usize { self.visible_issues().len() } fn move_selection(&mut self, delta: i32) { let len = self.visible_count(); if len == 0 { return; } let current = self.list_state.selected().unwrap_or(0); let new = if delta > 0 { (current + delta as usize).min(len - 1) } else { current.saturating_sub((-delta) as usize) }; self.list_state.select(Some(new)); self.scroll = 0; } 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 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('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('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()?; let visible = self.visible_issues(); visible.get(idx).map(|i| i.id.clone()) } fn selected_ref_name(&self) -> Option<String> { let id = self.selected_item_id()?; Some(format!("refs/collab/issues/{}", id)) } fn reload(&mut self, repo: &Repository) { if let Ok(issues) = state::list_issues(repo) { self.issues = issues; } if let Ok(patches) = state::list_patches(repo) { self.patches = patches; } let visible_len = self.visible_count(); if let Some(sel) = self.list_state.selected() { if sel >= visible_len { self.list_state.select(if visible_len > 0 { Some(visible_len - 1) } else { None }); } } } } 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, branch, .. } => { detail.push_str(&format!("\nTitle: {}\n", title)); detail.push_str(&format!("Base: {}\n", base_ref)); detail.push_str(&format!("Branch: {}\n", branch)); if !body.is_empty() { detail.push_str(&format!("\n{}\n", body)); } } Action::PatchRevise { body } => { 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)?; let mut app = App::new(issues, patches); terminal::enable_raw_mode()?; stdout().execute(EnterAlternateScreen)?; let mut terminal = Terminal::new(CrosstermBackend::new(stdout()))?; let result = run_loop(&mut terminal, &mut app, repo); terminal::disable_raw_mode()?; stdout().execute(LeaveAlternateScreen)?; result } fn run_loop( terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, app: &mut App, repo: &Repository, ) -> Result<(), Error> { loop { terminal.draw(|frame| ui(frame, app))?; if event::poll(Duration::from_millis(100))? { if let Event::Key(key) = event::read()? { app.status_msg = None; // clear status on any keypress // Input mode intercept — handle keys before normal bindings match app.input_mode { InputMode::Search => { match key.code { KeyCode::Esc => { app.input_mode = InputMode::Normal; app.search_query.clear(); let count = app.visible_count(); app.list_state .select(if count > 0 { Some(0) } else { None }); } KeyCode::Backspace => { app.search_query.pop(); let count = app.visible_count(); app.list_state .select(if count > 0 { Some(0) } else { None }); } KeyCode::Char(c) => { app.search_query.push(c); let count = app.visible_count(); app.list_state .select(if count > 0 { Some(0) } else { None }); } _ => {} } continue; } InputMode::CreateTitle => { match key.code { KeyCode::Esc => { app.input_mode = InputMode::Normal; app.input_buf.clear(); } KeyCode::Enter => { let title = app.input_buf.trim().to_string(); if title.is_empty() { app.input_mode = InputMode::Normal; app.input_buf.clear(); } else { app.create_title = title; app.input_buf.clear(); app.input_mode = InputMode::CreateBody; } } KeyCode::Backspace => { app.input_buf.pop(); } KeyCode::Char(c) => { app.input_buf.push(c); } _ => {} } continue; } InputMode::CreateBody => { match key.code { KeyCode::Esc => { // Submit with title only, no body let title = app.create_title.clone(); match issue_mod::open(repo, &title, "") { Ok(id) => { app.reload(repo); app.status_msg = Some(format!("Issue created: {:.8}", id)); } Err(e) => { app.status_msg = Some(format!("Error creating issue: {}", e)); } } app.input_mode = InputMode::Normal; app.input_buf.clear(); app.create_title.clear(); } KeyCode::Enter => { let title = app.create_title.clone(); let body = app.input_buf.clone(); match issue_mod::open(repo, &title, &body) { Ok(id) => { app.reload(repo); app.status_msg = Some(format!("Issue created: {:.8}", id)); } Err(e) => { app.status_msg = Some(format!("Error creating issue: {}", e)); } } app.input_mode = InputMode::Normal; app.input_buf.clear(); app.create_title.clear(); } KeyCode::Backspace => { app.input_buf.pop(); } KeyCode::Char(c) => { app.input_buf.push(c); } _ => {} } continue; } InputMode::Normal => {} } // Handle keys that need repo access or are run_loop-specific // before delegating to handle_key if app.mode == ViewMode::Details { match key.code { KeyCode::Char('/') => { app.input_mode = InputMode::Search; app.search_query.clear(); continue; } KeyCode::Char('n') => { app.input_mode = InputMode::CreateTitle; app.input_buf.clear(); app.create_title.clear(); continue; } KeyCode::Char('o') => { // Check out the relevant commit for local browsing let checkout_target = { 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.branch.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); } 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; } _ => {} } } match app.handle_key(key.code, key.modifiers) { KeyAction::Quit => return Ok(()), KeyAction::Reload => app.reload(repo), KeyAction::OpenCommitBrowser => { if let Some(ref_name) = app.selected_ref_name() { match crate::dag::walk_events(repo, &ref_name) { Ok(events) => { app.event_history = events; app.event_list_state = ListState::default(); if !app.event_history.is_empty() { app.event_list_state.select(Some(0)); } app.mode = ViewMode::CommitList; app.scroll = 0; } Err(e) => { app.status_msg = Some(format!("Error loading events: {}", e)); } } } } KeyAction::Continue => {} } } } } } fn ui(frame: &mut Frame, app: &mut App) { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Min(1), Constraint::Length(1), ]) .split(frame.area()); let main_area = chunks[0]; let footer_area = chunks[1]; let panes = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Percentage(35), Constraint::Percentage(65)]) .split(main_area); render_list(frame, app, panes[0]); render_detail(frame, app, panes[1]); render_footer(frame, app, footer_area); } fn render_list(frame: &mut Frame, app: &mut App, area: Rect) { let border_style = if app.pane == Pane::ItemList { Style::default().fg(Color::Yellow) } else { Style::default().fg(Color::DarkGray) }; let visible = app.visible_issues(); let items: Vec<ListItem> = visible .iter() .map(|i| { let status = match i.status { IssueStatus::Open => "open", IssueStatus::Closed => "closed", }; let style = match i.status { IssueStatus::Open => Style::default().fg(Color::Green), IssueStatus::Closed => Style::default().fg(Color::Red), }; ListItem::new(format!("{:.8} {:6} {}", i.id, status, i.title)).style(style) }) .collect(); let title = format!("Issues ({})", app.status_filter.label()); let list = List::new(items) .block( Block::default() .borders(Borders::ALL) .title(title) .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.list_state); } 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 visible = app.visible_issues(); let selected_idx = app.list_state.selected().unwrap_or(0); let content: Text = match visible.get(selected_idx) { Some(issue) => build_issue_detail(issue, &app.patches), None => Text::raw("No matches for current filter."), }; let block = Block::default() .borders(Borders::ALL) .title("Issue Details") .border_style(border_style); let para = Paragraph::new(content) .block(block) .wrap(Wrap { trim: false }) .scroll((app.scroll, 0)); frame.render_widget(para, area); } fn build_issue_detail(issue: &IssueState, patches: &[PatchState]) -> Text<'static> { let status = match issue.status { IssueStatus::Open => "open", IssueStatus::Closed => "closed", }; let mut lines: Vec<Line> = vec![ Line::from(vec![ Span::styled("Issue ", Style::default().add_modifier(Modifier::BOLD)), Span::styled( format!("{:.8}", issue.id), Style::default() .fg(Color::Yellow) .add_modifier(Modifier::BOLD), ), Span::raw(format!(" [{}]", status)), ]), Line::from(vec![ Span::styled("Title: ", Style::default().fg(Color::DarkGray)), Span::raw(issue.title.clone()), ]), Line::from(vec![ Span::styled("Author: ", Style::default().fg(Color::DarkGray)), Span::raw(format!("{} <{}>", issue.author.name, issue.author.email)), ]), Line::from(vec![ Span::styled("Created: ", Style::default().fg(Color::DarkGray)), Span::raw(issue.created_at.clone()), ]), ]; if !issue.labels.is_empty() { lines.push(Line::from(vec![ Span::styled("Labels: ", Style::default().fg(Color::DarkGray)), Span::raw(issue.labels.join(", ")), ])); } if !issue.assignees.is_empty() { lines.push(Line::from(vec![ Span::styled("Assign: ", Style::default().fg(Color::DarkGray)), Span::raw(issue.assignees.join(", ")), ])); } if let Some(ref reason) = issue.close_reason { lines.push(Line::from(vec![ Span::styled("Closed: ", Style::default().fg(Color::Red)), Span::raw(reason.clone()), ])); } if let Some(ref oid) = issue.closed_by { lines.push(Line::from(vec![ Span::styled("Commit: ", Style::default().fg(Color::DarkGray)), Span::styled( format!("{:.8}", oid), Style::default().fg(Color::Cyan), ), ])); } // Show patches that reference this issue via --fixes let fixing_patches: Vec<&PatchState> = patches .iter() .filter(|p| p.fixes.as_deref() == Some(&issue.id)) .collect(); if !fixing_patches.is_empty() { lines.push(Line::raw("")); lines.push(Line::styled( "--- Linked Patches ---", Style::default() .fg(Color::Magenta) .add_modifier(Modifier::BOLD), )); for p in &fixing_patches { let status = match p.status { PatchStatus::Open => ("open", Color::Green), PatchStatus::Closed => ("closed", Color::Red), PatchStatus::Merged => ("merged", Color::Cyan), }; lines.push(Line::from(vec![ Span::styled( format!("{:.8}", p.id), Style::default().fg(Color::Yellow), ), Span::raw(" "), Span::styled(status.0, Style::default().fg(status.1)), Span::raw(format!(" {}", p.title)), ])); } } if !issue.body.is_empty() { lines.push(Line::raw("")); for l in issue.body.lines() { lines.push(Line::raw(l.to_string())); } } if !issue.comments.is_empty() { lines.push(Line::raw("")); lines.push(Line::styled( "--- Comments ---", Style::default() .fg(Color::Blue) .add_modifier(Modifier::BOLD), )); for c in &issue.comments { lines.push(Line::raw("")); lines.push(Line::from(vec![ Span::styled( c.author.name.clone(), Style::default().add_modifier(Modifier::BOLD), ), Span::styled( format!(" ({})", c.timestamp), Style::default().fg(Color::DarkGray), ), ])); for l in c.body.lines() { lines.push(Line::raw(format!(" {}", l))); } } } Text::from(lines) } fn render_footer(frame: &mut Frame, app: &App, area: Rect) { match app.input_mode { InputMode::Search => { let max_query_len = (area.width as usize).saturating_sub(12); let display_query = if app.search_query.len() > max_query_len { &app.search_query[app.search_query.len() - max_query_len..] } else { &app.search_query }; let text = format!(" Search: {}_", display_query); let style = Style::default().bg(Color::Blue).fg(Color::White); let para = Paragraph::new(text).style(style); frame.render_widget(para, area); return; } InputMode::CreateTitle => { let max_len = (area.width as usize).saturating_sub(22); let display = if app.input_buf.len() > max_len { &app.input_buf[app.input_buf.len() - max_len..] } else { &app.input_buf }; let text = format!(" New issue - Title: {}_", display); let style = Style::default().bg(Color::Green).fg(Color::Black); let para = Paragraph::new(text).style(style); frame.render_widget(para, area); return; } InputMode::CreateBody => { let max_len = (area.width as usize).saturating_sub(21); let display = if app.input_buf.len() > max_len { &app.input_buf[app.input_buf.len() - max_len..] } else { &app.input_buf }; let text = format!(" New issue - Body: {}_ (Esc: skip)", display); let style = Style::default().bg(Color::Green).fg(Color::Black); let para = Paragraph::new(text).style(style); frame.render_widget(para, area); return; } InputMode::Normal => {} } // 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", ViewMode::Details => " c:events", }; let filter_hint = match app.status_filter { StatusFilter::Open => "a:show all", StatusFilter::All => "a:closed", StatusFilter::Closed => "a:open only", }; let text = format!( " j/k:navigate Tab:pane {}{} /:search n:new issue 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); } #[cfg(test)] mod tests { use super::*; use crate::event::Author; fn make_author() -> Author { Author { name: "test".into(), email: "test@test.com".into(), } } fn make_issue(id: &str, title: &str, status: IssueStatus) -> IssueState { IssueState { id: id.into(), title: title.into(), body: String::new(), status, close_reason: None, closed_by: None, labels: vec![], assignees: vec![], comments: vec![], created_at: String::new(), author: make_author(), } } fn make_patch(id: &str, title: &str, status: PatchStatus) -> PatchState { PatchState { id: id.into(), title: title.into(), body: String::new(), status, base_ref: "main".into(), fixes: None, branch: format!("feature/{}", id), comments: vec![], inline_comments: vec![], reviews: vec![], created_at: String::new(), author: make_author(), } } fn test_app() -> App { let issues = vec![ make_issue("i1", "Fix login bug", IssueStatus::Open), make_issue("i2", "Add dashboard feature", IssueStatus::Open), make_issue("i3", "Old closed issue", IssueStatus::Closed), ]; let patches = vec![ make_patch("p1", "Login fix patch", PatchStatus::Open), make_patch("p2", "Dashboard patch", PatchStatus::Closed), make_patch("p3", "Merged feature", PatchStatus::Merged), ]; App::new(issues, patches) } // T010: visible_issues filters by search_query (case-insensitive) #[test] fn test_visible_issues_text_filter() { let mut app = test_app(); app.status_filter = StatusFilter::All; app.search_query = "login".into(); let visible = app.visible_issues(); assert_eq!(visible.len(), 1); assert_eq!(visible[0].title, "Fix login bug"); } // T012: visible_issues returns all status-matching items when search_query is empty #[test] fn test_visible_issues_no_text_filter() { let mut app = test_app(); app.status_filter = StatusFilter::All; app.search_query.clear(); let visible = app.visible_issues(); assert_eq!(visible.len(), 3); } // T019: StatusFilter::next() cycles Open → All → Closed → Open #[test] fn test_status_filter_cycle() { assert_eq!(StatusFilter::Open.next(), StatusFilter::All); assert_eq!(StatusFilter::All.next(), StatusFilter::Closed); assert_eq!(StatusFilter::Closed.next(), StatusFilter::Open); } // T020: visible_issues returns only closed when status_filter is Closed #[test] fn test_visible_issues_closed_filter() { let mut app = test_app(); app.status_filter = StatusFilter::Closed; let visible = app.visible_issues(); assert_eq!(visible.len(), 1); assert_eq!(visible[0].title, "Old closed issue"); } // T025: combined status + text filter #[test] fn test_combined_filters() { let mut app = test_app(); app.status_filter = StatusFilter::Open; app.search_query = "login".into(); let visible = app.visible_issues(); assert_eq!(visible.len(), 1); assert_eq!(visible[0].title, "Fix login bug"); // Same query with Closed filter should return nothing app.status_filter = StatusFilter::Closed; let visible = app.visible_issues(); assert_eq!(visible.len(), 0); } // T026: Escape clears text filter but preserves status_filter #[test] fn test_escape_clears_text_preserves_status() { let mut app = test_app(); app.status_filter = StatusFilter::Closed; app.input_mode = InputMode::Search; app.search_query = "some query".into(); // Simulate Escape app.input_mode = InputMode::Normal; app.search_query.clear(); assert_eq!(app.status_filter, StatusFilter::Closed); assert_eq!(app.input_mode, InputMode::Normal); assert!(app.search_query.is_empty()); } #[test] fn test_create_title_mode_clears_on_escape() { let mut app = test_app(); app.input_mode = InputMode::CreateTitle; app.input_buf = "partial title".into(); // Simulate Escape app.input_mode = InputMode::Normal; app.input_buf.clear(); assert_eq!(app.input_mode, InputMode::Normal); assert!(app.input_buf.is_empty()); } #[test] fn test_create_title_transitions_to_body() { let mut app = test_app(); app.input_mode = InputMode::CreateTitle; app.input_buf = "My new issue".into(); // Simulate Enter with non-empty title let title = app.input_buf.trim().to_string(); assert!(!title.is_empty()); app.create_title = title; app.input_buf.clear(); app.input_mode = InputMode::CreateBody; assert_eq!(app.input_mode, InputMode::CreateBody); assert_eq!(app.create_title, "My new issue"); assert!(app.input_buf.is_empty()); } #[test] fn test_empty_title_dismissed() { let mut app = test_app(); app.input_mode = InputMode::CreateTitle; app.input_buf = " ".into(); // Simulate Enter with whitespace-only title let title = app.input_buf.trim().to_string(); if title.is_empty() { app.input_mode = InputMode::Normal; app.input_buf.clear(); } assert_eq!(app.input_mode, InputMode::Normal); } // ── Commit browser test helpers ───────────────────────────────────── 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(), fixes: None, branch: format!("feature/p{:07x}", i), comments: Vec::new(), inline_comments: Vec::new(), reviews: Vec::new(), created_at: "2026-01-01T00:00:00Z".to_string(), author: test_author(), }) .collect() } fn make_app(issues: usize, patches: usize) -> App { App::new(make_test_issues(issues), make_test_patches(patches)) } fn 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(), }, clock: 0, }, ), ( oid2, crate::event::Event { timestamp: "2026-01-02T00:00:00Z".to_string(), author: Author { name: "Other User".to_string(), email: "other@example.com".to_string(), }, action: Action::IssueComment { body: "A comment on the issue".to_string(), }, clock: 0, }, ), ( oid3, crate::event::Event { timestamp: "2026-01-03T00:00:00Z".to_string(), author: test_author(), action: Action::IssueClose { reason: Some("fixed".to_string()), }, clock: 0, }, ), ] } // ── action_type_label tests ────────────────────────────────────────── #[test] fn test_action_type_label_issue_open() { let action = Action::IssueOpen { title: "t".to_string(), body: "b".to_string(), }; assert_eq!(action_type_label(&action), "Issue Open"); } #[test] fn test_action_type_label_issue_comment() { let action = Action::IssueComment { body: "b".to_string(), }; assert_eq!(action_type_label(&action), "Issue Comment"); } #[test] fn test_action_type_label_issue_close() { let action = Action::IssueClose { reason: None }; assert_eq!(action_type_label(&action), "Issue Close"); } #[test] fn test_action_type_label_issue_reopen() { assert_eq!(action_type_label(&Action::IssueReopen), "Issue Reopen"); } #[test] fn test_action_type_label_patch_create() { let action = Action::PatchCreate { title: "t".to_string(), body: "b".to_string(), base_ref: "main".to_string(), branch: "feature/test".to_string(), fixes: None, }; 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(), }, clock: 0, }; let detail = format_event_detail(&oid, &event); assert!(detail.contains("aaaaaaa")); assert!(detail.contains("Test User <test@example.com>")); assert!(detail.contains("2026-01-01T00:00:00Z")); assert!(detail.contains("Issue Open")); assert!(detail.contains("Title: My Issue")); assert!(detail.contains("Description here")); } #[test] fn test_format_event_detail_issue_close_with_reason() { let oid = Oid::from_str("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb").unwrap(); let event = crate::event::Event { timestamp: "2026-02-01T00:00:00Z".to_string(), author: test_author(), action: Action::IssueClose { reason: Some("resolved".to_string()), }, clock: 0, }; let detail = format_event_detail(&oid, &event); assert!(detail.contains("Issue Close")); assert!(detail.contains("Reason: resolved")); } #[test] fn test_format_event_detail_patch_review() { let oid = Oid::from_str("cccccccccccccccccccccccccccccccccccccccc").unwrap(); let event = crate::event::Event { timestamp: "2026-03-01T00:00:00Z".to_string(), author: test_author(), action: Action::PatchReview { verdict: ReviewVerdict::Approve, body: "Looks good!".to_string(), }, clock: 0, }; let detail = format_event_detail(&oid, &event); assert!(detail.contains("Patch Review")); assert!(detail.contains("Approve")); assert!(detail.contains("Looks good!")); } #[test] fn test_format_event_detail_short_oid() { let oid = Oid::from_str("1234567890abcdef1234567890abcdef12345678").unwrap(); let event = crate::event::Event { timestamp: "2026-01-01T00:00:00Z".to_string(), author: test_author(), action: Action::IssueReopen, clock: 0, }; let detail = format_event_detail(&oid, &event); assert!(detail.contains("1234567")); assert!(detail.contains("Commit: 1234567\n")); } // ── handle_key tests for 'c' key ───────────────────────────────────── #[test] fn test_handle_key_c_in_detail_pane_returns_open_commit_browser() { let mut app = make_app(3, 0); app.pane = Pane::Detail; app.list_state.select(Some(0)); let result = app.handle_key(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_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_none_when_empty() { let app = make_app(0, 0); assert_eq!(app.selected_ref_name(), None); } // ── Render tests ───────────────────────────────────────────────────── #[test] fn test_render_issues_tab() { let mut app = make_app(3, 2); app.status_filter = StatusFilter::All; let buf = render_app(&mut app); assert_buffer_contains(&buf, "Issues"); assert_buffer_contains(&buf, "00000000"); assert_buffer_contains(&buf, "00000001"); assert_buffer_contains(&buf, "00000002"); assert_buffer_contains(&buf, "Issue 0"); } #[test] fn test_render_empty_state() { let mut app = make_app(0, 0); let buf = render_app(&mut app); assert_buffer_contains(&buf, "No matches for current filter."); } #[test] fn test_render_footer_keys() { let mut app = make_app(3, 3); let buf = render_app(&mut app); assert_buffer_contains(&buf, "j/k:navigate"); assert_buffer_contains(&buf, "Tab:pane"); assert_buffer_contains(&buf, "c:events"); } #[test] fn test_render_commit_list() { let mut app = make_app(3, 0); app.event_history = make_test_event_history(); app.event_list_state.select(Some(0)); app.mode = ViewMode::CommitList; let buf = render_app(&mut app); assert_buffer_contains(&buf, "Event History"); assert_buffer_contains(&buf, "Issue Open"); assert_buffer_contains(&buf, "Issue Comment"); assert_buffer_contains(&buf, "Issue Close"); } #[test] fn test_render_commit_detail() { let mut app = make_app(3, 0); app.event_history = make_test_event_history(); app.event_list_state.select(Some(0)); app.mode = ViewMode::CommitDetail; let buf = render_app(&mut app); assert_buffer_contains(&buf, "Event Detail"); assert_buffer_contains(&buf, "aaaaaaa"); assert_buffer_contains(&buf, "Test User"); assert_buffer_contains(&buf, "Issue Open"); } #[test] fn test_render_commit_list_footer() { let mut app = make_app(3, 0); app.mode = ViewMode::CommitList; let buf = render_app(&mut app); assert_buffer_contains(&buf, "Esc:back"); } #[test] fn test_render_commit_detail_footer() { let mut app = make_app(3, 0); app.mode = ViewMode::CommitDetail; let buf = render_app(&mut app); assert_buffer_contains(&buf, "Esc:back"); assert_buffer_contains(&buf, "j/k:scroll"); } #[test] fn test_render_small_terminal() { let mut app = make_app(3, 3); let backend = TestBackend::new(20, 10); let mut terminal = Terminal::new(backend).unwrap(); terminal.draw(|frame| ui(frame, &mut app)).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"); } } diff --git a/src/tui/events.rs b/src/tui/events.rs new file mode 100644 index 0000000..ff02163 --- /dev/null +++ b/src/tui/events.rs @@ -0,0 +1,225 @@ use std::io::{self, stdout}; use std::time::Duration; use crossterm::event::{self, Event, KeyCode}; use crossterm::terminal::{self, LeaveAlternateScreen}; use crossterm::ExecutableCommand; use git2::Repository; use ratatui::prelude::*; use ratatui::widgets::ListState; use crate::error::Error; use crate::issue as issue_mod; use super::state::{App, InputMode, KeyAction, ViewMode}; use super::widgets::ui; pub(crate) fn run_loop( terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, app: &mut App, repo: &Repository, ) -> Result<(), Error> { loop { terminal.draw(|frame| ui(frame, app))?; if event::poll(Duration::from_millis(100))? { if let Event::Key(key) = event::read()? { app.status_msg = None; // clear status on any keypress // Input mode intercept — handle keys before normal bindings match app.input_mode { InputMode::Search => { match key.code { KeyCode::Esc => { app.input_mode = InputMode::Normal; app.search_query.clear(); let count = app.visible_count(); app.list_state .select(if count > 0 { Some(0) } else { None }); } KeyCode::Backspace => { app.search_query.pop(); let count = app.visible_count(); app.list_state .select(if count > 0 { Some(0) } else { None }); } KeyCode::Char(c) => { app.search_query.push(c); let count = app.visible_count(); app.list_state .select(if count > 0 { Some(0) } else { None }); } _ => {} } continue; } InputMode::CreateTitle => { match key.code { KeyCode::Esc => { app.input_mode = InputMode::Normal; app.input_buf.clear(); } KeyCode::Enter => { let title = app.input_buf.trim().to_string(); if title.is_empty() { app.input_mode = InputMode::Normal; app.input_buf.clear(); } else { app.create_title = title; app.input_buf.clear(); app.input_mode = InputMode::CreateBody; } } KeyCode::Backspace => { app.input_buf.pop(); } KeyCode::Char(c) => { app.input_buf.push(c); } _ => {} } continue; } InputMode::CreateBody => { match key.code { KeyCode::Esc => { // Submit with title only, no body let title = app.create_title.clone(); match issue_mod::open(repo, &title, "") { Ok(id) => { app.reload(repo); app.status_msg = Some(format!("Issue created: {:.8}", id)); } Err(e) => { app.status_msg = Some(format!("Error creating issue: {}", e)); } } app.input_mode = InputMode::Normal; app.input_buf.clear(); app.create_title.clear(); } KeyCode::Enter => { let title = app.create_title.clone(); let body = app.input_buf.clone(); match issue_mod::open(repo, &title, &body) { Ok(id) => { app.reload(repo); app.status_msg = Some(format!("Issue created: {:.8}", id)); } Err(e) => { app.status_msg = Some(format!("Error creating issue: {}", e)); } } app.input_mode = InputMode::Normal; app.input_buf.clear(); app.create_title.clear(); } KeyCode::Backspace => { app.input_buf.pop(); } KeyCode::Char(c) => { app.input_buf.push(c); } _ => {} } continue; } InputMode::Normal => {} } // Handle keys that need repo access or are run_loop-specific // before delegating to handle_key if app.mode == ViewMode::Details { match key.code { KeyCode::Char('/') => { app.input_mode = InputMode::Search; app.search_query.clear(); continue; } KeyCode::Char('n') => { app.input_mode = InputMode::CreateTitle; app.input_buf.clear(); app.create_title.clear(); continue; } KeyCode::Char('o') => { // Check out the relevant commit for local browsing let checkout_target = { 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.branch.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); } 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; } _ => {} } } match app.handle_key(key.code, key.modifiers) { KeyAction::Quit => return Ok(()), KeyAction::Reload => app.reload(repo), KeyAction::OpenCommitBrowser => { if let Some(ref_name) = app.selected_ref_name() { match crate::dag::walk_events(repo, &ref_name) { Ok(events) => { app.event_history = events; app.event_list_state = ListState::default(); if !app.event_history.is_empty() { app.event_list_state.select(Some(0)); } app.mode = ViewMode::CommitList; app.scroll = 0; } Err(e) => { app.status_msg = Some(format!("Error loading events: {}", e)); } } } } KeyAction::Continue => {} } } } } } diff --git a/src/tui/mod.rs b/src/tui/mod.rs new file mode 100644 index 0000000..ee95f8b --- /dev/null +++ b/src/tui/mod.rs @@ -0,0 +1,1006 @@ mod events; mod state; mod widgets; use std::io::stdout; use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen}; use crossterm::ExecutableCommand; use git2::Repository; use ratatui::prelude::*; use crate::error::Error; use crate::state as app_state; use self::events::run_loop; use self::state::App; pub fn run(repo: &Repository) -> Result<(), Error> { let issues = app_state::list_issues(repo)?; let patches = app_state::list_patches(repo)?; let mut app = App::new(issues, patches); terminal::enable_raw_mode()?; stdout().execute(EnterAlternateScreen)?; let mut terminal = Terminal::new(CrosstermBackend::new(stdout()))?; let result = run_loop(&mut terminal, &mut app, repo); terminal::disable_raw_mode()?; stdout().execute(LeaveAlternateScreen)?; result } #[cfg(test)] mod tests { use super::state::*; use super::widgets::*; use crate::event::{Action, Author, ReviewVerdict}; use crate::state::{IssueState, IssueStatus, PatchState, PatchStatus}; use git2::Oid; use ratatui::backend::TestBackend; use ratatui::buffer::Buffer; use ratatui::prelude::*; use ratatui::widgets::ListState; fn make_author() -> Author { Author { name: "test".into(), email: "test@test.com".into(), } } fn make_issue(id: &str, title: &str, status: IssueStatus) -> IssueState { IssueState { id: id.into(), title: title.into(), body: String::new(), status, close_reason: None, closed_by: None, labels: vec![], assignees: vec![], comments: vec![], created_at: String::new(), author: make_author(), } } fn make_patch(id: &str, title: &str, status: PatchStatus) -> PatchState { PatchState { id: id.into(), title: title.into(), body: String::new(), status, base_ref: "main".into(), fixes: None, branch: format!("feature/{}", id), comments: vec![], inline_comments: vec![], reviews: vec![], created_at: String::new(), author: make_author(), } } fn test_app() -> App { let issues = vec![ make_issue("i1", "Fix login bug", IssueStatus::Open), make_issue("i2", "Add dashboard feature", IssueStatus::Open), make_issue("i3", "Old closed issue", IssueStatus::Closed), ]; let patches = vec![ make_patch("p1", "Login fix patch", PatchStatus::Open), make_patch("p2", "Dashboard patch", PatchStatus::Closed), make_patch("p3", "Merged feature", PatchStatus::Merged), ]; App::new(issues, patches) } // T010: visible_issues filters by search_query (case-insensitive) #[test] fn test_visible_issues_text_filter() { let mut app = test_app(); app.status_filter = StatusFilter::All; app.search_query = "login".into(); let visible = app.visible_issues(); assert_eq!(visible.len(), 1); assert_eq!(visible[0].title, "Fix login bug"); } // T012: visible_issues returns all status-matching items when search_query is empty #[test] fn test_visible_issues_no_text_filter() { let mut app = test_app(); app.status_filter = StatusFilter::All; app.search_query.clear(); let visible = app.visible_issues(); assert_eq!(visible.len(), 3); } // T019: StatusFilter::next() cycles Open -> All -> Closed -> Open #[test] fn test_status_filter_cycle() { assert_eq!(StatusFilter::Open.next(), StatusFilter::All); assert_eq!(StatusFilter::All.next(), StatusFilter::Closed); assert_eq!(StatusFilter::Closed.next(), StatusFilter::Open); } // T020: visible_issues returns only closed when status_filter is Closed #[test] fn test_visible_issues_closed_filter() { let mut app = test_app(); app.status_filter = StatusFilter::Closed; let visible = app.visible_issues(); assert_eq!(visible.len(), 1); assert_eq!(visible[0].title, "Old closed issue"); } // T025: combined status + text filter #[test] fn test_combined_filters() { let mut app = test_app(); app.status_filter = StatusFilter::Open; app.search_query = "login".into(); let visible = app.visible_issues(); assert_eq!(visible.len(), 1); assert_eq!(visible[0].title, "Fix login bug"); // Same query with Closed filter should return nothing app.status_filter = StatusFilter::Closed; let visible = app.visible_issues(); assert_eq!(visible.len(), 0); } // T026: Escape clears text filter but preserves status_filter #[test] fn test_escape_clears_text_preserves_status() { let mut app = test_app(); app.status_filter = StatusFilter::Closed; app.input_mode = InputMode::Search; app.search_query = "some query".into(); // Simulate Escape app.input_mode = InputMode::Normal; app.search_query.clear(); assert_eq!(app.status_filter, StatusFilter::Closed); assert_eq!(app.input_mode, InputMode::Normal); assert!(app.search_query.is_empty()); } #[test] fn test_create_title_mode_clears_on_escape() { let mut app = test_app(); app.input_mode = InputMode::CreateTitle; app.input_buf = "partial title".into(); // Simulate Escape app.input_mode = InputMode::Normal; app.input_buf.clear(); assert_eq!(app.input_mode, InputMode::Normal); assert!(app.input_buf.is_empty()); } #[test] fn test_create_title_transitions_to_body() { let mut app = test_app(); app.input_mode = InputMode::CreateTitle; app.input_buf = "My new issue".into(); // Simulate Enter with non-empty title let title = app.input_buf.trim().to_string(); assert!(!title.is_empty()); app.create_title = title; app.input_buf.clear(); app.input_mode = InputMode::CreateBody; assert_eq!(app.input_mode, InputMode::CreateBody); assert_eq!(app.create_title, "My new issue"); assert!(app.input_buf.is_empty()); } #[test] fn test_empty_title_dismissed() { let mut app = test_app(); app.input_mode = InputMode::CreateTitle; app.input_buf = " ".into(); // Simulate Enter with whitespace-only title let title = app.input_buf.trim().to_string(); if title.is_empty() { app.input_mode = InputMode::Normal; app.input_buf.clear(); } assert_eq!(app.input_mode, InputMode::Normal); } // ── Commit browser test helpers ───────────────────────────────────── fn test_author() -> Author { Author { name: "Test User".to_string(), email: "test@example.com".to_string(), } } fn make_test_issues(n: usize) -> Vec<IssueState> { (0..n) .map(|i| IssueState { id: format!("{:08x}", i), title: format!("Issue {}", i), body: format!("Body for issue {}", i), status: if i % 2 == 0 { IssueStatus::Open } else { IssueStatus::Closed }, close_reason: if i % 2 == 1 { Some("done".to_string()) } else { None }, closed_by: None, labels: vec![], assignees: vec![], comments: Vec::new(), 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(), fixes: None, branch: format!("feature/p{:07x}", i), comments: Vec::new(), inline_comments: Vec::new(), reviews: Vec::new(), created_at: "2026-01-01T00:00:00Z".to_string(), author: test_author(), }) .collect() } fn make_app(issues: usize, patches: usize) -> App { App::new(make_test_issues(issues), make_test_patches(patches)) } fn 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(), }, clock: 0, }, ), ( oid2, crate::event::Event { timestamp: "2026-01-02T00:00:00Z".to_string(), author: Author { name: "Other User".to_string(), email: "other@example.com".to_string(), }, action: Action::IssueComment { body: "A comment on the issue".to_string(), }, clock: 0, }, ), ( oid3, crate::event::Event { timestamp: "2026-01-03T00:00:00Z".to_string(), author: test_author(), action: Action::IssueClose { reason: Some("fixed".to_string()), }, clock: 0, }, ), ] } // ── action_type_label tests ────────────────────────────────────────── #[test] fn test_action_type_label_issue_open() { let action = Action::IssueOpen { title: "t".to_string(), body: "b".to_string(), }; assert_eq!(action_type_label(&action), "Issue Open"); } #[test] fn test_action_type_label_issue_comment() { let action = Action::IssueComment { body: "b".to_string(), }; assert_eq!(action_type_label(&action), "Issue Comment"); } #[test] fn test_action_type_label_issue_close() { let action = Action::IssueClose { reason: None }; assert_eq!(action_type_label(&action), "Issue Close"); } #[test] fn test_action_type_label_issue_reopen() { assert_eq!(action_type_label(&Action::IssueReopen), "Issue Reopen"); } #[test] fn test_action_type_label_patch_create() { let action = Action::PatchCreate { title: "t".to_string(), body: "b".to_string(), base_ref: "main".to_string(), branch: "feature/test".to_string(), fixes: None, }; 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(), }, clock: 0, }; let detail = format_event_detail(&oid, &event); assert!(detail.contains("aaaaaaa")); assert!(detail.contains("Test User <test@example.com>")); assert!(detail.contains("2026-01-01T00:00:00Z")); assert!(detail.contains("Issue Open")); assert!(detail.contains("Title: My Issue")); assert!(detail.contains("Description here")); } #[test] fn test_format_event_detail_issue_close_with_reason() { let oid = Oid::from_str("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb").unwrap(); let event = crate::event::Event { timestamp: "2026-02-01T00:00:00Z".to_string(), author: test_author(), action: Action::IssueClose { reason: Some("resolved".to_string()), }, clock: 0, }; let detail = format_event_detail(&oid, &event); assert!(detail.contains("Issue Close")); assert!(detail.contains("Reason: resolved")); } #[test] fn test_format_event_detail_patch_review() { let oid = Oid::from_str("cccccccccccccccccccccccccccccccccccccccc").unwrap(); let event = crate::event::Event { timestamp: "2026-03-01T00:00:00Z".to_string(), author: test_author(), action: Action::PatchReview { verdict: ReviewVerdict::Approve, body: "Looks good!".to_string(), }, clock: 0, }; let detail = format_event_detail(&oid, &event); assert!(detail.contains("Patch Review")); assert!(detail.contains("Approve")); assert!(detail.contains("Looks good!")); } #[test] fn test_format_event_detail_short_oid() { let oid = Oid::from_str("1234567890abcdef1234567890abcdef12345678").unwrap(); let event = crate::event::Event { timestamp: "2026-01-01T00:00:00Z".to_string(), author: test_author(), action: Action::IssueReopen, clock: 0, }; let detail = format_event_detail(&oid, &event); assert!(detail.contains("1234567")); assert!(detail.contains("Commit: 1234567\n")); } // ── handle_key tests for 'c' key ───────────────────────────────────── #[test] fn test_handle_key_c_in_detail_pane_returns_open_commit_browser() { let mut app = make_app(3, 0); app.pane = Pane::Detail; app.list_state.select(Some(0)); let result = app.handle_key( crossterm::event::KeyCode::Char('c'), crossterm::event::KeyModifiers::empty(), ); assert_eq!(result, KeyAction::OpenCommitBrowser); } #[test] fn test_handle_key_c_in_item_list_pane_is_noop() { let mut app = make_app(3, 0); app.pane = Pane::ItemList; app.list_state.select(Some(0)); let result = app.handle_key( crossterm::event::KeyCode::Char('c'), crossterm::event::KeyModifiers::empty(), ); assert_eq!(result, KeyAction::Continue); } #[test] fn test_handle_key_c_no_selection_is_noop() { let mut app = make_app(0, 0); app.pane = Pane::Detail; let result = app.handle_key( crossterm::event::KeyCode::Char('c'), crossterm::event::KeyModifiers::empty(), ); assert_eq!(result, KeyAction::Continue); } #[test] fn test_handle_key_ctrl_c_still_quits() { let mut app = make_app(3, 0); let result = app.handle_key( crossterm::event::KeyCode::Char('c'), crossterm::event::KeyModifiers::CONTROL, ); assert_eq!(result, KeyAction::Quit); } // ── CommitList navigation tests ────────────────────────────────────── #[test] fn test_commit_list_navigate_down() { let mut app = make_app(3, 0); app.event_history = make_test_event_history(); app.event_list_state.select(Some(0)); app.mode = ViewMode::CommitList; app.handle_key( crossterm::event::KeyCode::Char('j'), crossterm::event::KeyModifiers::empty(), ); assert_eq!(app.event_list_state.selected(), Some(1)); } #[test] fn test_commit_list_navigate_up() { let mut app = make_app(3, 0); app.event_history = make_test_event_history(); app.event_list_state.select(Some(2)); app.mode = ViewMode::CommitList; app.handle_key( crossterm::event::KeyCode::Char('k'), crossterm::event::KeyModifiers::empty(), ); assert_eq!(app.event_list_state.selected(), Some(1)); } #[test] fn test_commit_list_navigate_clamp_bottom() { let mut app = make_app(3, 0); app.event_history = make_test_event_history(); app.event_list_state.select(Some(2)); app.mode = ViewMode::CommitList; app.handle_key( crossterm::event::KeyCode::Down, crossterm::event::KeyModifiers::empty(), ); assert_eq!(app.event_list_state.selected(), Some(2)); } #[test] fn test_commit_list_navigate_clamp_top() { let mut app = make_app(3, 0); app.event_history = make_test_event_history(); app.event_list_state.select(Some(0)); app.mode = ViewMode::CommitList; app.handle_key( crossterm::event::KeyCode::Up, crossterm::event::KeyModifiers::empty(), ); assert_eq!(app.event_list_state.selected(), Some(0)); } #[test] fn test_commit_list_escape_returns_to_details() { let mut app = make_app(3, 0); app.event_history = make_test_event_history(); app.event_list_state.select(Some(1)); app.mode = ViewMode::CommitList; let result = app.handle_key( crossterm::event::KeyCode::Esc, crossterm::event::KeyModifiers::empty(), ); assert_eq!(result, KeyAction::Continue); assert_eq!(app.mode, ViewMode::Details); assert!(app.event_history.is_empty()); assert_eq!(app.event_list_state.selected(), None); } #[test] fn test_commit_list_q_quits() { let mut app = make_app(3, 0); app.mode = ViewMode::CommitList; let result = app.handle_key( crossterm::event::KeyCode::Char('q'), crossterm::event::KeyModifiers::empty(), ); assert_eq!(result, KeyAction::Quit); } // ── CommitDetail tests ─────────────────────────────────────────────── #[test] fn test_commit_list_enter_opens_detail() { let mut app = make_app(3, 0); app.event_history = make_test_event_history(); app.event_list_state.select(Some(1)); app.mode = ViewMode::CommitList; let result = app.handle_key( crossterm::event::KeyCode::Enter, crossterm::event::KeyModifiers::empty(), ); assert_eq!(result, KeyAction::Continue); assert_eq!(app.mode, ViewMode::CommitDetail); assert_eq!(app.scroll, 0); } #[test] fn test_commit_list_enter_no_selection_stays() { let mut app = make_app(3, 0); app.event_history = make_test_event_history(); app.event_list_state = ListState::default(); app.mode = ViewMode::CommitList; app.handle_key( crossterm::event::KeyCode::Enter, crossterm::event::KeyModifiers::empty(), ); assert_eq!(app.mode, ViewMode::CommitList); } #[test] fn test_commit_detail_escape_returns_to_list() { let mut app = make_app(3, 0); app.event_history = make_test_event_history(); app.event_list_state.select(Some(0)); app.mode = ViewMode::CommitDetail; app.scroll = 5; let result = app.handle_key( crossterm::event::KeyCode::Esc, crossterm::event::KeyModifiers::empty(), ); assert_eq!(result, KeyAction::Continue); assert_eq!(app.mode, ViewMode::CommitList); assert_eq!(app.scroll, 0); assert_eq!(app.event_history.len(), 3); } #[test] fn test_commit_detail_scroll() { let mut app = make_app(3, 0); app.mode = ViewMode::CommitDetail; app.scroll = 0; app.handle_key( crossterm::event::KeyCode::Char('j'), crossterm::event::KeyModifiers::empty(), ); assert_eq!(app.scroll, 1); app.handle_key( crossterm::event::KeyCode::Char('j'), crossterm::event::KeyModifiers::empty(), ); assert_eq!(app.scroll, 2); app.handle_key( crossterm::event::KeyCode::Char('k'), crossterm::event::KeyModifiers::empty(), ); assert_eq!(app.scroll, 1); } #[test] fn test_commit_detail_page_scroll() { let mut app = make_app(3, 0); app.mode = ViewMode::CommitDetail; app.scroll = 0; app.handle_key( crossterm::event::KeyCode::PageDown, crossterm::event::KeyModifiers::empty(), ); assert_eq!(app.scroll, 20); app.handle_key( crossterm::event::KeyCode::PageUp, crossterm::event::KeyModifiers::empty(), ); assert_eq!(app.scroll, 0); } #[test] fn test_commit_detail_q_quits() { let mut app = make_app(3, 0); app.mode = ViewMode::CommitDetail; let result = app.handle_key( crossterm::event::KeyCode::Char('q'), crossterm::event::KeyModifiers::empty(), ); assert_eq!(result, KeyAction::Quit); } // ── Guard tests ────────────────────────────────────────────────────── #[test] fn test_c_ignored_in_commit_list_mode() { let mut app = make_app(3, 0); app.mode = ViewMode::CommitList; let result = app.handle_key( crossterm::event::KeyCode::Char('c'), crossterm::event::KeyModifiers::empty(), ); assert_eq!(result, KeyAction::Continue); assert_eq!(app.mode, ViewMode::CommitList); } #[test] fn test_c_ignored_in_commit_detail_mode() { let mut app = make_app(3, 0); app.mode = ViewMode::CommitDetail; let result = app.handle_key( crossterm::event::KeyCode::Char('c'), crossterm::event::KeyModifiers::empty(), ); assert_eq!(result, KeyAction::Continue); assert_eq!(app.mode, ViewMode::CommitDetail); } // ── handle_key basic tests ─────────────────────────────────────────── #[test] fn test_handle_key_quit() { let mut app = make_app(3, 3); assert_eq!( app.handle_key( crossterm::event::KeyCode::Char('q'), crossterm::event::KeyModifiers::empty() ), KeyAction::Quit ); } #[test] fn test_handle_key_quit_esc() { let mut app = make_app(3, 3); assert_eq!( app.handle_key( crossterm::event::KeyCode::Esc, crossterm::event::KeyModifiers::empty() ), KeyAction::Quit ); } #[test] fn test_handle_key_quit_ctrl_c() { let mut app = make_app(3, 3); assert_eq!( app.handle_key( crossterm::event::KeyCode::Char('c'), crossterm::event::KeyModifiers::CONTROL ), KeyAction::Quit ); } #[test] fn test_handle_key_pane_toggle() { let mut app = make_app(3, 3); assert_eq!(app.pane, Pane::ItemList); app.handle_key( crossterm::event::KeyCode::Tab, crossterm::event::KeyModifiers::empty(), ); assert_eq!(app.pane, Pane::Detail); app.handle_key( crossterm::event::KeyCode::Enter, crossterm::event::KeyModifiers::empty(), ); assert_eq!(app.pane, Pane::ItemList); } #[test] fn test_scroll_in_detail_pane() { let mut app = make_app(3, 0); app.list_state.select(Some(0)); app.pane = Pane::Detail; app.scroll = 0; app.handle_key( crossterm::event::KeyCode::Char('j'), crossterm::event::KeyModifiers::empty(), ); assert_eq!(app.scroll, 1); assert_eq!(app.list_state.selected(), Some(0)); app.handle_key( crossterm::event::KeyCode::Char('k'), crossterm::event::KeyModifiers::empty(), ); assert_eq!(app.scroll, 0); } #[test] fn test_handle_key_reload() { let mut app = make_app(3, 3); assert_eq!( app.handle_key( crossterm::event::KeyCode::Char('r'), crossterm::event::KeyModifiers::empty() ), KeyAction::Reload ); } // ── selected_ref_name tests ────────────────────────────────────────── #[test] fn test_selected_ref_name_issues() { let app = make_app(3, 0); let ref_name = app.selected_ref_name(); assert_eq!( ref_name, Some("refs/collab/issues/00000000".to_string()) ); } #[test] fn test_selected_ref_name_none_when_empty() { let app = make_app(0, 0); assert_eq!(app.selected_ref_name(), None); } // ── Render tests ───────────────────────────────────────────────────── #[test] fn test_render_issues_tab() { let mut app = make_app(3, 2); app.status_filter = StatusFilter::All; let buf = render_app(&mut app); assert_buffer_contains(&buf, "Issues"); assert_buffer_contains(&buf, "00000000"); assert_buffer_contains(&buf, "00000001"); assert_buffer_contains(&buf, "00000002"); assert_buffer_contains(&buf, "Issue 0"); } #[test] fn test_render_empty_state() { let mut app = make_app(0, 0); let buf = render_app(&mut app); assert_buffer_contains(&buf, "No matches for current filter."); } #[test] fn test_render_footer_keys() { let mut app = make_app(3, 3); let buf = render_app(&mut app); assert_buffer_contains(&buf, "j/k:navigate"); assert_buffer_contains(&buf, "Tab:pane"); assert_buffer_contains(&buf, "c:events"); } #[test] fn test_render_commit_list() { let mut app = make_app(3, 0); app.event_history = make_test_event_history(); app.event_list_state.select(Some(0)); app.mode = ViewMode::CommitList; let buf = render_app(&mut app); assert_buffer_contains(&buf, "Event History"); assert_buffer_contains(&buf, "Issue Open"); assert_buffer_contains(&buf, "Issue Comment"); assert_buffer_contains(&buf, "Issue Close"); } #[test] fn test_render_commit_detail() { let mut app = make_app(3, 0); app.event_history = make_test_event_history(); app.event_list_state.select(Some(0)); app.mode = ViewMode::CommitDetail; let buf = render_app(&mut app); assert_buffer_contains(&buf, "Event Detail"); assert_buffer_contains(&buf, "aaaaaaa"); assert_buffer_contains(&buf, "Test User"); assert_buffer_contains(&buf, "Issue Open"); } #[test] fn test_render_commit_list_footer() { let mut app = make_app(3, 0); app.mode = ViewMode::CommitList; let buf = render_app(&mut app); assert_buffer_contains(&buf, "Esc:back"); } #[test] fn test_render_commit_detail_footer() { let mut app = make_app(3, 0); app.mode = ViewMode::CommitDetail; let buf = render_app(&mut app); assert_buffer_contains(&buf, "Esc:back"); assert_buffer_contains(&buf, "j/k:scroll"); } #[test] fn test_render_small_terminal() { let mut app = make_app(3, 3); let backend = TestBackend::new(20, 10); let mut terminal = Terminal::new(backend).unwrap(); terminal.draw(|frame| ui(frame, &mut app)).unwrap(); } // ── Integration: full browse flow ──────────────────────────────────── #[test] fn test_full_commit_browse_flow() { let mut app = make_app(3, 0); app.pane = Pane::Detail; app.list_state.select(Some(0)); let action = app.handle_key( crossterm::event::KeyCode::Char('c'), crossterm::event::KeyModifiers::empty(), ); assert_eq!(action, KeyAction::OpenCommitBrowser); app.event_history = make_test_event_history(); app.event_list_state.select(Some(0)); app.mode = ViewMode::CommitList; app.scroll = 0; app.handle_key( crossterm::event::KeyCode::Char('j'), crossterm::event::KeyModifiers::empty(), ); assert_eq!(app.event_list_state.selected(), Some(1)); app.handle_key( crossterm::event::KeyCode::Enter, crossterm::event::KeyModifiers::empty(), ); assert_eq!(app.mode, ViewMode::CommitDetail); app.handle_key( crossterm::event::KeyCode::Char('j'), crossterm::event::KeyModifiers::empty(), ); assert_eq!(app.scroll, 1); app.handle_key( crossterm::event::KeyCode::Esc, crossterm::event::KeyModifiers::empty(), ); assert_eq!(app.mode, ViewMode::CommitList); assert_eq!(app.scroll, 0); app.handle_key( crossterm::event::KeyCode::Esc, crossterm::event::KeyModifiers::empty(), ); assert_eq!(app.mode, ViewMode::Details); assert!(app.event_history.is_empty()); } // ── Status message render test ─────────────────────────────────────── #[test] fn test_render_status_message() { let mut app = make_app(3, 0); app.status_msg = Some("Error loading events: ref not found".to_string()); let buf = render_app(&mut app); assert_buffer_contains(&buf, "Error loading events"); } } diff --git a/src/tui/state.rs b/src/tui/state.rs new file mode 100644 index 0000000..527293f --- /dev/null +++ b/src/tui/state.rs @@ -0,0 +1,301 @@ use crossterm::event::{KeyCode, KeyModifiers}; use git2::{Oid, Repository}; use ratatui::widgets::ListState; use crate::state::{self, IssueState, IssueStatus, PatchState}; #[derive(Debug, PartialEq)] pub(crate) enum Pane { ItemList, Detail, } #[derive(Debug, PartialEq)] pub(crate) enum ViewMode { Details, CommitList, CommitDetail, } #[derive(Debug, PartialEq)] pub(crate) enum KeyAction { Continue, Quit, Reload, OpenCommitBrowser, } #[derive(Debug, PartialEq, Clone, Copy)] pub(crate) enum StatusFilter { Open, Closed, All, } impl StatusFilter { pub(crate) fn next(self) -> Self { match self { StatusFilter::Open => StatusFilter::All, StatusFilter::All => StatusFilter::Closed, StatusFilter::Closed => StatusFilter::Open, } } pub(crate) fn label(self) -> &'static str { match self { StatusFilter::Open => "open", StatusFilter::Closed => "closed", StatusFilter::All => "all", } } } #[derive(Debug, PartialEq)] pub(crate) enum InputMode { Normal, Search, CreateTitle, CreateBody, } pub(crate) struct App { pub(crate) issues: Vec<IssueState>, pub(crate) patches: Vec<PatchState>, pub(crate) list_state: ListState, pub(crate) scroll: u16, pub(crate) pane: Pane, pub(crate) mode: ViewMode, pub(crate) status_filter: StatusFilter, pub(crate) search_query: String, pub(crate) input_mode: InputMode, pub(crate) input_buf: String, pub(crate) create_title: String, pub(crate) status_msg: Option<String>, pub(crate) event_history: Vec<(Oid, crate::event::Event)>, pub(crate) event_list_state: ListState, } impl App { pub(crate) fn new(issues: Vec<IssueState>, patches: Vec<PatchState>) -> Self { let mut list_state = ListState::default(); if !issues.is_empty() { list_state.select(Some(0)); } Self { issues, patches, list_state, scroll: 0, pane: Pane::ItemList, mode: ViewMode::Details, status_filter: StatusFilter::Open, search_query: String::new(), input_mode: InputMode::Normal, input_buf: String::new(), create_title: String::new(), status_msg: None, event_history: Vec::new(), event_list_state: ListState::default(), } } pub(crate) fn matches_search(&self, title: &str) -> bool { if self.search_query.is_empty() { return true; } title .to_lowercase() .contains(&self.search_query.to_lowercase()) } pub(crate) fn visible_issues(&self) -> Vec<&IssueState> { self.issues .iter() .filter(|i| match self.status_filter { StatusFilter::Open => i.status == IssueStatus::Open, StatusFilter::Closed => i.status == IssueStatus::Closed, StatusFilter::All => true, }) .filter(|i| self.matches_search(&i.title)) .collect() } pub(crate) fn visible_count(&self) -> usize { self.visible_issues().len() } pub(crate) fn move_selection(&mut self, delta: i32) { let len = self.visible_count(); if len == 0 { return; } let current = self.list_state.selected().unwrap_or(0); let new = if delta > 0 { (current + delta as usize).min(len - 1) } else { current.saturating_sub((-delta) as usize) }; self.list_state.select(Some(new)); self.scroll = 0; } pub(crate) 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 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('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('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, } } pub(crate) fn selected_item_id(&self) -> Option<String> { let idx = self.list_state.selected()?; let visible = self.visible_issues(); visible.get(idx).map(|i| i.id.clone()) } pub(crate) fn selected_ref_name(&self) -> Option<String> { let id = self.selected_item_id()?; Some(format!("refs/collab/issues/{}", id)) } pub(crate) fn reload(&mut self, repo: &Repository) { if let Ok(issues) = state::list_issues(repo) { self.issues = issues; } if let Ok(patches) = state::list_patches(repo) { self.patches = patches; } let visible_len = self.visible_count(); if let Some(sel) = self.list_state.selected() { if sel >= visible_len { self.list_state.select(if visible_len > 0 { Some(visible_len - 1) } else { None }); } } } } diff --git a/src/tui/widgets.rs b/src/tui/widgets.rs new file mode 100644 index 0000000..e786584 --- /dev/null +++ b/src/tui/widgets.rs @@ -0,0 +1,463 @@ use git2::Oid; use ratatui::prelude::*; use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph, Wrap}; use crate::event::Action; use crate::state::{IssueState, IssueStatus, PatchState, PatchStatus}; use super::state::{App, InputMode, Pane, StatusFilter, ViewMode}; pub(crate) 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", } } pub(crate) 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, branch, .. } => { detail.push_str(&format!("\nTitle: {}\n", title)); detail.push_str(&format!("Base: {}\n", base_ref)); detail.push_str(&format!("Branch: {}\n", branch)); if !body.is_empty() { detail.push_str(&format!("\n{}\n", body)); } } Action::PatchRevise { body } => { 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(crate) fn ui(frame: &mut Frame, app: &mut App) { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Min(1), Constraint::Length(1), ]) .split(frame.area()); let main_area = chunks[0]; let footer_area = chunks[1]; let panes = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Percentage(35), Constraint::Percentage(65)]) .split(main_area); render_list(frame, app, panes[0]); render_detail(frame, app, panes[1]); render_footer(frame, app, footer_area); } fn render_list(frame: &mut Frame, app: &mut App, area: Rect) { let border_style = if app.pane == Pane::ItemList { Style::default().fg(Color::Yellow) } else { Style::default().fg(Color::DarkGray) }; let visible = app.visible_issues(); let items: Vec<ListItem> = visible .iter() .map(|i| { let status = match i.status { IssueStatus::Open => "open", IssueStatus::Closed => "closed", }; let style = match i.status { IssueStatus::Open => Style::default().fg(Color::Green), IssueStatus::Closed => Style::default().fg(Color::Red), }; ListItem::new(format!("{:.8} {:6} {}", i.id, status, i.title)).style(style) }) .collect(); let title = format!("Issues ({})", app.status_filter.label()); let list = List::new(items) .block( Block::default() .borders(Borders::ALL) .title(title) .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.list_state); } 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 visible = app.visible_issues(); let selected_idx = app.list_state.selected().unwrap_or(0); let content: Text = match visible.get(selected_idx) { Some(issue) => build_issue_detail(issue, &app.patches), None => Text::raw("No matches for current filter."), }; let block = Block::default() .borders(Borders::ALL) .title("Issue Details") .border_style(border_style); let para = Paragraph::new(content) .block(block) .wrap(Wrap { trim: false }) .scroll((app.scroll, 0)); frame.render_widget(para, area); } fn build_issue_detail(issue: &IssueState, patches: &[PatchState]) -> Text<'static> { let status = match issue.status { IssueStatus::Open => "open", IssueStatus::Closed => "closed", }; let mut lines: Vec<Line> = vec![ Line::from(vec![ Span::styled("Issue ", Style::default().add_modifier(Modifier::BOLD)), Span::styled( format!("{:.8}", issue.id), Style::default() .fg(Color::Yellow) .add_modifier(Modifier::BOLD), ), Span::raw(format!(" [{}]", status)), ]), Line::from(vec![ Span::styled("Title: ", Style::default().fg(Color::DarkGray)), Span::raw(issue.title.clone()), ]), Line::from(vec![ Span::styled("Author: ", Style::default().fg(Color::DarkGray)), Span::raw(format!("{} <{}>", issue.author.name, issue.author.email)), ]), Line::from(vec![ Span::styled("Created: ", Style::default().fg(Color::DarkGray)), Span::raw(issue.created_at.clone()), ]), ]; if !issue.labels.is_empty() { lines.push(Line::from(vec![ Span::styled("Labels: ", Style::default().fg(Color::DarkGray)), Span::raw(issue.labels.join(", ")), ])); } if !issue.assignees.is_empty() { lines.push(Line::from(vec![ Span::styled("Assign: ", Style::default().fg(Color::DarkGray)), Span::raw(issue.assignees.join(", ")), ])); } if let Some(ref reason) = issue.close_reason { lines.push(Line::from(vec![ Span::styled("Closed: ", Style::default().fg(Color::Red)), Span::raw(reason.clone()), ])); } if let Some(ref oid) = issue.closed_by { lines.push(Line::from(vec![ Span::styled("Commit: ", Style::default().fg(Color::DarkGray)), Span::styled( format!("{:.8}", oid), Style::default().fg(Color::Cyan), ), ])); } // Show patches that reference this issue via --fixes let fixing_patches: Vec<&PatchState> = patches .iter() .filter(|p| p.fixes.as_deref() == Some(&issue.id)) .collect(); if !fixing_patches.is_empty() { lines.push(Line::raw("")); lines.push(Line::styled( "--- Linked Patches ---", Style::default() .fg(Color::Magenta) .add_modifier(Modifier::BOLD), )); for p in &fixing_patches { let status = match p.status { PatchStatus::Open => ("open", Color::Green), PatchStatus::Closed => ("closed", Color::Red), PatchStatus::Merged => ("merged", Color::Cyan), }; lines.push(Line::from(vec![ Span::styled( format!("{:.8}", p.id), Style::default().fg(Color::Yellow), ), Span::raw(" "), Span::styled(status.0, Style::default().fg(status.1)), Span::raw(format!(" {}", p.title)), ])); } } if !issue.body.is_empty() { lines.push(Line::raw("")); for l in issue.body.lines() { lines.push(Line::raw(l.to_string())); } } if !issue.comments.is_empty() { lines.push(Line::raw("")); lines.push(Line::styled( "--- Comments ---", Style::default() .fg(Color::Blue) .add_modifier(Modifier::BOLD), )); for c in &issue.comments { lines.push(Line::raw("")); lines.push(Line::from(vec![ Span::styled( c.author.name.clone(), Style::default().add_modifier(Modifier::BOLD), ), Span::styled( format!(" ({})", c.timestamp), Style::default().fg(Color::DarkGray), ), ])); for l in c.body.lines() { lines.push(Line::raw(format!(" {}", l))); } } } Text::from(lines) } fn render_footer(frame: &mut Frame, app: &App, area: Rect) { match app.input_mode { InputMode::Search => { let max_query_len = (area.width as usize).saturating_sub(12); let display_query = if app.search_query.len() > max_query_len { &app.search_query[app.search_query.len() - max_query_len..] } else { &app.search_query }; let text = format!(" Search: {}_", display_query); let style = Style::default().bg(Color::Blue).fg(Color::White); let para = Paragraph::new(text).style(style); frame.render_widget(para, area); return; } InputMode::CreateTitle => { let max_len = (area.width as usize).saturating_sub(22); let display = if app.input_buf.len() > max_len { &app.input_buf[app.input_buf.len() - max_len..] } else { &app.input_buf }; let text = format!(" New issue - Title: {}_", display); let style = Style::default().bg(Color::Green).fg(Color::Black); let para = Paragraph::new(text).style(style); frame.render_widget(para, area); return; } InputMode::CreateBody => { let max_len = (area.width as usize).saturating_sub(21); let display = if app.input_buf.len() > max_len { &app.input_buf[app.input_buf.len() - max_len..] } else { &app.input_buf }; let text = format!(" New issue - Body: {}_ (Esc: skip)", display); let style = Style::default().bg(Color::Green).fg(Color::Black); let para = Paragraph::new(text).style(style); frame.render_widget(para, area); return; } InputMode::Normal => {} } // 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", ViewMode::Details => " c:events", }; let filter_hint = match app.status_filter { StatusFilter::Open => "a:show all", StatusFilter::All => "a:closed", StatusFilter::Closed => "a:open only", }; let text = format!( " j/k:navigate Tab:pane {}{} /:search n:new issue 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); }