e24a78c9
Add TUI patch detail view with revision-aware diff
a73x 2026-03-21 19:38
Navigate from issue detail to linked patch detail via 'p' key. Shows patch metadata, revision list, reviews, inline comments, thread comments, and scrollable diff with syntax coloring. Supports revision cycling ([/]), interdiff toggle (d), and staleness warning display. Esc returns to issue detail. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
diff --git a/src/lib.rs b/src/lib.rs index 223d0e4..2d11ea4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -23,7 +23,7 @@ use git2::Repository; use state::{IssueStatus, PatchStatus}; /// Check if the reviewer's base ref has moved ahead of the patch's latest revision. fn staleness_warning(repo: &Repository, patch: &state::PatchState) -> Option<String> { pub fn staleness_warning(repo: &Repository, patch: &state::PatchState) -> Option<String> { let latest_commit = &patch.revisions.last()?.commit; let commit_oid = git2::Oid::from_str(latest_commit).ok()?; let base_ref = format!("refs/heads/{}", patch.base_ref); diff --git a/src/tui/events.rs b/src/tui/events.rs index 3171aaf..b095aea 100644 --- a/src/tui/events.rs +++ b/src/tui/events.rs @@ -10,6 +10,7 @@ use ratatui::widgets::ListState; use crate::error::Error; use crate::issue as issue_mod; use crate::patch as patch_mod; use super::state::{App, InputMode, KeyAction, ViewMode}; use super::widgets::ui; @@ -197,7 +198,30 @@ pub(crate) fn run_loop( match app.handle_key(key.code, key.modifiers) { KeyAction::Quit => return Ok(()), KeyAction::Reload if app.mode == ViewMode::PatchDetail => { // Regenerate diff for current revision/interdiff state regenerate_patch_diff(app, repo); } KeyAction::Reload => app.reload(repo), KeyAction::OpenPatchDetail => { if let Some(patch) = app.linked_patch_for_selected().cloned() { // Check staleness let warning = crate::staleness_warning(repo, &patch); app.patch_revision_idx = patch.revisions.len().saturating_sub(1); app.patch_interdiff_mode = false; app.patch_scroll = 0; // Generate initial diff (latest revision vs base) let diff = generate_patch_diff_for(repo, &patch, app.patch_revision_idx, false); app.patch_diff = diff; app.current_patch = Some(patch); app.mode = ViewMode::PatchDetail; if let Some(w) = warning { app.status_msg = Some(w); } } } KeyAction::OpenCommitBrowser => { if let Some(ref_name) = app.selected_ref_name() { match crate::dag::walk_events(repo, &ref_name) { @@ -223,3 +247,43 @@ pub(crate) fn run_loop( } } } fn generate_patch_diff_for( repo: &Repository, patch: &crate::state::PatchState, rev_idx: usize, interdiff_mode: bool, ) -> String { if patch.revisions.is_empty() { return "(no revisions)".to_string(); } let rev = &patch.revisions[rev_idx]; if interdiff_mode && rev_idx > 0 { let from_rev = patch.revisions[rev_idx - 1].number; let to_rev = rev.number; match patch_mod::interdiff(repo, patch, from_rev, to_rev) { Ok(d) => d, Err(e) => format!("(error generating interdiff: {})", e), } } else { // Diff at specific revision vs base match patch_mod::diff(repo, &patch.id, Some(rev.number), None) { Ok(d) => d, Err(e) => format!("(error generating diff: {})", e), } } } fn regenerate_patch_diff(app: &mut App, repo: &Repository) { if let Some(ref patch) = app.current_patch { let diff = generate_patch_diff_for( repo, patch, app.patch_revision_idx, app.patch_interdiff_mode, ); app.patch_diff = diff; } } diff --git a/src/tui/mod.rs b/src/tui/mod.rs index bf73cc5..51f03d6 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -1019,4 +1019,368 @@ mod tests { let buf = render_app(&mut app); assert_buffer_contains(&buf, "Error loading events"); } // ── Patch detail view tests ───────────────────────────────────────── fn make_patch_with_revisions() -> PatchState { use crate::state::Revision; PatchState { id: "deadbeef".into(), title: "Fix the thing".into(), body: "Detailed description".into(), status: PatchStatus::Open, base_ref: "main".into(), fixes: Some("i1".into()), branch: "feature/fix-thing".into(), comments: vec![crate::state::Comment { author: make_author(), body: "Thread comment".into(), timestamp: "2026-01-05T00:00:00Z".into(), commit_id: Oid::from_str("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap(), }], inline_comments: vec![crate::state::InlineComment { author: make_author(), file: "src/main.rs".into(), line: 42, body: "Nit: rename this".into(), timestamp: "2026-01-03T00:00:00Z".into(), revision: Some(1), }], reviews: vec![crate::state::Review { author: make_author(), verdict: ReviewVerdict::Approve, body: "LGTM".into(), timestamp: "2026-01-04T00:00:00Z".into(), revision: Some(2), }], revisions: vec![ Revision { number: 1, commit: "aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111".into(), tree: "bbbb1111bbbb1111bbbb1111bbbb1111bbbb1111".into(), body: None, timestamp: "2026-01-01T00:00:00Z".into(), }, Revision { number: 2, commit: "aaaa2222aaaa2222aaaa2222aaaa2222aaaa2222".into(), tree: "bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222".into(), body: Some("Addressed review comments".into()), timestamp: "2026-01-02T00:00:00Z".into(), }, ], created_at: "2026-01-01T00:00:00Z".into(), last_updated: "2026-01-04T00:00:00Z".into(), author: make_author(), } } #[test] fn test_p_key_opens_patch_detail_when_linked_patch_exists() { let mut app = test_app(); // Issue i1 has a linked patch (p1 has fixes=None, so we need to set it) app.patches[0].fixes = Some("i1".into()); app.pane = Pane::Detail; app.list_state.select(Some(0)); // selects issue i1 let result = app.handle_key( crossterm::event::KeyCode::Char('p'), crossterm::event::KeyModifiers::empty(), ); assert_eq!(result, KeyAction::OpenPatchDetail); } #[test] fn test_p_key_noop_when_no_linked_patch() { let mut app = test_app(); // No patches fix any issues by default app.pane = Pane::Detail; app.list_state.select(Some(0)); let result = app.handle_key( crossterm::event::KeyCode::Char('p'), crossterm::event::KeyModifiers::empty(), ); assert_eq!(result, KeyAction::Continue); } #[test] fn test_p_key_noop_in_item_list_pane() { let mut app = test_app(); app.patches[0].fixes = Some("i1".into()); app.pane = Pane::ItemList; app.list_state.select(Some(0)); let result = app.handle_key( crossterm::event::KeyCode::Char('p'), crossterm::event::KeyModifiers::empty(), ); assert_eq!(result, KeyAction::Continue); } #[test] fn test_patch_detail_esc_returns_to_details() { let mut app = test_app(); app.current_patch = Some(make_patch_with_revisions()); app.mode = ViewMode::PatchDetail; app.patch_scroll = 5; app.patch_revision_idx = 1; app.patch_interdiff_mode = true; app.patch_diff = "some diff".into(); let result = app.handle_key( crossterm::event::KeyCode::Esc, crossterm::event::KeyModifiers::empty(), ); assert_eq!(result, KeyAction::Continue); assert_eq!(app.mode, ViewMode::Details); assert!(app.current_patch.is_none()); assert!(app.patch_diff.is_empty()); assert_eq!(app.patch_scroll, 0); assert_eq!(app.patch_revision_idx, 0); assert!(!app.patch_interdiff_mode); } #[test] fn test_patch_detail_q_quits() { let mut app = test_app(); app.mode = ViewMode::PatchDetail; let result = app.handle_key( crossterm::event::KeyCode::Char('q'), crossterm::event::KeyModifiers::empty(), ); assert_eq!(result, KeyAction::Quit); } #[test] fn test_patch_detail_scroll() { let mut app = test_app(); app.mode = ViewMode::PatchDetail; app.current_patch = Some(make_patch_with_revisions()); app.patch_scroll = 0; app.handle_key( crossterm::event::KeyCode::Char('j'), crossterm::event::KeyModifiers::empty(), ); assert_eq!(app.patch_scroll, 1); app.handle_key( crossterm::event::KeyCode::Char('j'), crossterm::event::KeyModifiers::empty(), ); assert_eq!(app.patch_scroll, 2); app.handle_key( crossterm::event::KeyCode::Char('k'), crossterm::event::KeyModifiers::empty(), ); assert_eq!(app.patch_scroll, 1); } #[test] fn test_patch_detail_page_scroll() { let mut app = test_app(); app.mode = ViewMode::PatchDetail; app.patch_scroll = 0; app.handle_key( crossterm::event::KeyCode::PageDown, crossterm::event::KeyModifiers::empty(), ); assert_eq!(app.patch_scroll, 20); app.handle_key( crossterm::event::KeyCode::PageUp, crossterm::event::KeyModifiers::empty(), ); assert_eq!(app.patch_scroll, 0); } #[test] fn test_patch_detail_revision_navigation() { let mut app = test_app(); app.mode = ViewMode::PatchDetail; app.current_patch = Some(make_patch_with_revisions()); app.patch_revision_idx = 0; // Navigate forward let result = app.handle_key( crossterm::event::KeyCode::Char(']'), crossterm::event::KeyModifiers::empty(), ); assert_eq!(result, KeyAction::Reload); assert_eq!(app.patch_revision_idx, 1); assert_eq!(app.patch_scroll, 0); // Can't go past last revision let result = app.handle_key( crossterm::event::KeyCode::Char(']'), crossterm::event::KeyModifiers::empty(), ); assert_eq!(result, KeyAction::Continue); assert_eq!(app.patch_revision_idx, 1); // Navigate backward let result = app.handle_key( crossterm::event::KeyCode::Char('['), crossterm::event::KeyModifiers::empty(), ); assert_eq!(result, KeyAction::Reload); assert_eq!(app.patch_revision_idx, 0); // Can't go below 0 let result = app.handle_key( crossterm::event::KeyCode::Char('['), crossterm::event::KeyModifiers::empty(), ); assert_eq!(result, KeyAction::Continue); assert_eq!(app.patch_revision_idx, 0); } #[test] fn test_patch_detail_interdiff_toggle() { let mut app = test_app(); app.mode = ViewMode::PatchDetail; app.current_patch = Some(make_patch_with_revisions()); app.patch_interdiff_mode = false; let result = app.handle_key( crossterm::event::KeyCode::Char('d'), crossterm::event::KeyModifiers::empty(), ); assert_eq!(result, KeyAction::Reload); assert!(app.patch_interdiff_mode); assert_eq!(app.patch_scroll, 0); let result = app.handle_key( crossterm::event::KeyCode::Char('d'), crossterm::event::KeyModifiers::empty(), ); assert_eq!(result, KeyAction::Reload); assert!(!app.patch_interdiff_mode); } #[test] fn test_render_patch_detail() { let mut app = make_app(3, 0); app.mode = ViewMode::PatchDetail; app.current_patch = Some(make_patch_with_revisions()); app.patch_revision_idx = 1; app.patch_diff = "+added line\n-removed line\n context".into(); let buf = render_app(&mut app); assert_buffer_contains(&buf, "Patch Detail"); assert_buffer_contains(&buf, "Fix the thing"); assert_buffer_contains(&buf, "deadbeef"); assert_buffer_contains(&buf, "feature/fix-thing"); } #[test] fn test_render_patch_detail_shows_reviews() { let mut app = make_app(3, 0); app.mode = ViewMode::PatchDetail; app.current_patch = Some(make_patch_with_revisions()); app.patch_revision_idx = 1; app.patch_diff = String::new(); let buf = render_app(&mut app); assert_buffer_contains(&buf, "Reviews"); assert_buffer_contains(&buf, "approve"); assert_buffer_contains(&buf, "LGTM"); } #[test] fn test_render_patch_detail_footer() { let mut app = make_app(3, 0); app.mode = ViewMode::PatchDetail; let buf = render_app(&mut app); assert_buffer_contains(&buf, "Esc:back"); assert_buffer_contains(&buf, "[/]:revision"); // "d:interdiff" may be truncated at 80 cols, check prefix assert_buffer_contains(&buf, "d:inter"); } #[test] fn test_render_patch_detail_no_patch_loaded() { let mut app = make_app(3, 0); app.mode = ViewMode::PatchDetail; app.current_patch = None; let buf = render_app(&mut app); assert_buffer_contains(&buf, "No patch loaded"); } #[test] fn test_patch_detail_ctrl_c_quits() { let mut app = test_app(); app.mode = ViewMode::PatchDetail; let result = app.handle_key( crossterm::event::KeyCode::Char('c'), crossterm::event::KeyModifiers::CONTROL, ); assert_eq!(result, KeyAction::Quit); } #[test] fn test_full_patch_detail_flow() { let mut app = test_app(); app.patches[0].fixes = Some("i1".into()); app.pane = Pane::Detail; app.list_state.select(Some(0)); // Press p to open patch detail let action = app.handle_key( crossterm::event::KeyCode::Char('p'), crossterm::event::KeyModifiers::empty(), ); assert_eq!(action, KeyAction::OpenPatchDetail); // Simulate what events.rs does app.current_patch = Some(make_patch_with_revisions()); app.patch_revision_idx = 1; app.patch_diff = "diff content".into(); app.mode = ViewMode::PatchDetail; // Navigate revisions app.handle_key( crossterm::event::KeyCode::Char('['), crossterm::event::KeyModifiers::empty(), ); assert_eq!(app.patch_revision_idx, 0); // Toggle interdiff app.handle_key( crossterm::event::KeyCode::Char('d'), crossterm::event::KeyModifiers::empty(), ); assert!(app.patch_interdiff_mode); // Scroll app.handle_key( crossterm::event::KeyCode::Char('j'), crossterm::event::KeyModifiers::empty(), ); assert_eq!(app.patch_scroll, 1); // Escape back app.handle_key( crossterm::event::KeyCode::Esc, crossterm::event::KeyModifiers::empty(), ); assert_eq!(app.mode, ViewMode::Details); assert!(app.current_patch.is_none()); } #[test] fn test_linked_patch_for_selected() { let mut app = test_app(); app.patches[0].fixes = Some("i1".into()); app.list_state.select(Some(0)); let patch = app.linked_patch_for_selected(); assert!(patch.is_some()); assert_eq!(patch.unwrap().title, "Login fix patch"); // Second issue has no linked patch app.list_state.select(Some(1)); assert!(app.linked_patch_for_selected().is_none()); } } diff --git a/src/tui/state.rs b/src/tui/state.rs index 527293f..1380918 100644 --- a/src/tui/state.rs +++ b/src/tui/state.rs @@ -15,6 +15,7 @@ pub(crate) enum ViewMode { Details, CommitList, CommitDetail, PatchDetail, } #[derive(Debug, PartialEq)] @@ -23,6 +24,7 @@ pub(crate) enum KeyAction { Quit, Reload, OpenCommitBrowser, OpenPatchDetail, } #[derive(Debug, PartialEq, Clone, Copy)] @@ -73,6 +75,12 @@ pub(crate) struct App { pub(crate) status_msg: Option<String>, pub(crate) event_history: Vec<(Oid, crate::event::Event)>, pub(crate) event_list_state: ListState, // Patch detail view state pub(crate) current_patch: Option<PatchState>, pub(crate) patch_diff: String, pub(crate) patch_scroll: u16, pub(crate) patch_revision_idx: usize, pub(crate) patch_interdiff_mode: bool, } impl App { @@ -96,6 +104,11 @@ impl App { status_msg: None, event_history: Vec::new(), event_list_state: ListState::default(), current_patch: None, patch_diff: String::new(), patch_scroll: 0, patch_revision_idx: 0, patch_interdiff_mode: false, } } @@ -139,7 +152,77 @@ impl App { self.scroll = 0; } /// Find the first linked patch for the currently selected issue. pub(crate) fn linked_patch_for_selected(&self) -> Option<&PatchState> { let idx = self.list_state.selected()?; let visible = self.visible_issues(); let issue = visible.get(idx)?; self.patches .iter() .find(|p| p.fixes.as_deref() == Some(&issue.id)) } pub(crate) fn handle_key(&mut self, code: KeyCode, modifiers: KeyModifiers) -> KeyAction { // Handle PatchDetail mode if self.mode == ViewMode::PatchDetail { match code { KeyCode::Esc => { self.mode = ViewMode::Details; self.current_patch = None; self.patch_diff.clear(); self.patch_scroll = 0; self.patch_revision_idx = 0; self.patch_interdiff_mode = false; 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.patch_scroll = self.patch_scroll.saturating_add(1); return KeyAction::Continue; } KeyCode::Char('k') | KeyCode::Up => { self.patch_scroll = self.patch_scroll.saturating_sub(1); return KeyAction::Continue; } KeyCode::PageDown => { self.patch_scroll = self.patch_scroll.saturating_add(20); return KeyAction::Continue; } KeyCode::PageUp => { self.patch_scroll = self.patch_scroll.saturating_sub(20); return KeyAction::Continue; } KeyCode::Char(']') => { if let Some(ref patch) = self.current_patch { let max = patch.revisions.len().saturating_sub(1); if self.patch_revision_idx < max { self.patch_revision_idx += 1; self.patch_scroll = 0; return KeyAction::Reload; // signal to regenerate diff } } return KeyAction::Continue; } KeyCode::Char('[') => { if self.patch_revision_idx > 0 { self.patch_revision_idx -= 1; self.patch_scroll = 0; return KeyAction::Reload; // signal to regenerate diff } return KeyAction::Continue; } KeyCode::Char('d') => { self.patch_interdiff_mode = !self.patch_interdiff_mode; self.patch_scroll = 0; return KeyAction::Reload; // signal to regenerate diff } _ => return KeyAction::Continue, } } // Handle CommitDetail mode first if self.mode == ViewMode::CommitDetail { match code { @@ -226,6 +309,14 @@ impl App { KeyAction::Continue } } KeyCode::Char('p') => { // Open patch detail: only when in detail pane with a linked patch if self.pane == Pane::Detail && self.linked_patch_for_selected().is_some() { KeyAction::OpenPatchDetail } else { KeyAction::Continue } } KeyCode::Char('j') | KeyCode::Down => { if self.pane == Pane::ItemList { self.move_selection(1); diff --git a/src/tui/widgets.rs b/src/tui/widgets.rs index d514475..40079a1 100644 --- a/src/tui/widgets.rs +++ b/src/tui/widgets.rs @@ -2,7 +2,7 @@ use git2::Oid; use ratatui::prelude::*; use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph, Wrap}; use crate::event::Action; use crate::event::{Action, ReviewVerdict}; use crate::state::{IssueState, IssueStatus, PatchState, PatchStatus}; use super::state::{App, InputMode, Pane, StatusFilter, ViewMode}; @@ -188,6 +188,21 @@ fn render_detail(frame: &mut Frame, app: &mut App, area: Rect) { Style::default().fg(Color::DarkGray) }; // Handle patch detail mode if app.mode == ViewMode::PatchDetail { let content = build_patch_detail_text(app); let block = Block::default() .borders(Borders::ALL) .title("Patch Detail") .border_style(border_style); let para = Paragraph::new(content) .block(block) .wrap(Wrap { trim: false }) .scroll((app.patch_scroll, 0)); frame.render_widget(para, area); return; } // Handle commit browser modes if app.mode == ViewMode::CommitList { let items: Vec<ListItem> = app @@ -394,6 +409,245 @@ fn build_issue_detail(issue: &IssueState, patches: &[PatchState]) -> Text<'stati Text::from(lines) } fn build_patch_detail_text(app: &App) -> Text<'static> { let patch = match &app.current_patch { Some(p) => p, None => return Text::raw("No patch loaded."), }; let status_str = match patch.status { PatchStatus::Open => "open", PatchStatus::Closed => "closed", PatchStatus::Merged => "merged", }; let status_color = match patch.status { PatchStatus::Open => Color::Green, PatchStatus::Closed => Color::Red, PatchStatus::Merged => Color::Cyan, }; let mut lines: Vec<Line> = vec![ Line::from(vec![ Span::styled("Patch ", Style::default().add_modifier(Modifier::BOLD)), Span::styled( format!("{:.8}", patch.id), Style::default() .fg(Color::Yellow) .add_modifier(Modifier::BOLD), ), Span::raw(" "), Span::styled(status_str, Style::default().fg(status_color)), ]), Line::from(vec![ Span::styled("Title: ", Style::default().fg(Color::DarkGray)), Span::raw(patch.title.clone()), ]), Line::from(vec![ Span::styled("Author: ", Style::default().fg(Color::DarkGray)), Span::raw(format!("{} <{}>", patch.author.name, patch.author.email)), ]), Line::from(vec![ Span::styled("Branch: ", Style::default().fg(Color::DarkGray)), Span::raw(patch.branch.clone()), ]), Line::from(vec![ Span::styled("Base: ", Style::default().fg(Color::DarkGray)), Span::raw(patch.base_ref.clone()), ]), Line::from(vec![ Span::styled("Revisions:", Style::default().fg(Color::DarkGray)), Span::raw(format!(" {}", patch.revisions.len())), ]), ]; if let Some(ref fixes) = patch.fixes { lines.push(Line::from(vec![ Span::styled("Fixes: ", Style::default().fg(Color::DarkGray)), Span::raw(format!("{:.8}", fixes)), ])); } // Staleness warning (stored in status_msg or computed text) if let Some(ref warning) = app.status_msg { if warning.contains("behind") { lines.push(Line::raw("")); lines.push(Line::styled( warning.clone(), Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD), )); } } // Revision list if !patch.revisions.is_empty() { lines.push(Line::raw("")); lines.push(Line::styled( "--- Revisions ---", Style::default() .fg(Color::Magenta) .add_modifier(Modifier::BOLD), )); for (i, rev) in patch.revisions.iter().enumerate() { let short = if rev.commit.len() >= 8 { &rev.commit[..8] } else { &rev.commit }; let marker = if i == app.patch_revision_idx { "> " } else { " " }; let label = if i == 0 { " (initial)" } else { "" }; lines.push(Line::from(vec![ Span::raw(format!("{}r{} {} {}{}", marker, rev.number, short, rev.timestamp, label)), ])); } } // Reviews if !patch.reviews.is_empty() { lines.push(Line::raw("")); lines.push(Line::styled( "--- Reviews ---", Style::default() .fg(Color::Blue) .add_modifier(Modifier::BOLD), )); for review in &patch.reviews { let verdict_str = match review.verdict { ReviewVerdict::Approve => "approve", ReviewVerdict::RequestChanges => "request-changes", ReviewVerdict::Comment => "comment", ReviewVerdict::Reject => "reject", }; let verdict_color = match review.verdict { ReviewVerdict::Approve => Color::Green, ReviewVerdict::RequestChanges => Color::Yellow, ReviewVerdict::Comment => Color::White, ReviewVerdict::Reject => Color::Red, }; let rev_label = review .revision .map(|r| format!(" (r{})", r)) .unwrap_or_default(); lines.push(Line::from(vec![ Span::styled( review.author.name.clone(), Style::default().add_modifier(Modifier::BOLD), ), Span::raw(format!(" {} ", review.timestamp)), Span::styled(verdict_str, Style::default().fg(verdict_color)), Span::raw(rev_label), ])); if !review.body.is_empty() { for l in review.body.lines() { lines.push(Line::raw(format!(" {}", l))); } } } } // Inline comments if !patch.inline_comments.is_empty() { lines.push(Line::raw("")); lines.push(Line::styled( "--- Inline Comments ---", Style::default() .fg(Color::Blue) .add_modifier(Modifier::BOLD), )); for ic in &patch.inline_comments { let rev_label = ic .revision .map(|r| format!(" (r{})", r)) .unwrap_or_default(); lines.push(Line::from(vec![ Span::styled( ic.author.name.clone(), Style::default().add_modifier(Modifier::BOLD), ), Span::raw(format!(" {}:{}", ic.file, ic.line)), Span::raw(rev_label), ])); for l in ic.body.lines() { lines.push(Line::raw(format!(" {}", l))); } } } // Thread comments if !patch.comments.is_empty() { lines.push(Line::raw("")); lines.push(Line::styled( "--- Comments ---", Style::default() .fg(Color::Blue) .add_modifier(Modifier::BOLD), )); for c in &patch.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))); } } } // Diff header lines.push(Line::raw("")); let diff_mode = if app.patch_interdiff_mode { let rev_idx = app.patch_revision_idx; if rev_idx > 0 { let from_rev = patch.revisions.get(rev_idx - 1).map(|r| r.number).unwrap_or(0); let to_rev = patch.revisions.get(rev_idx).map(|r| r.number).unwrap_or(0); format!("--- Interdiff r{} -> r{} ---", from_rev, to_rev) } else { "--- Diff vs base (no previous revision) ---".to_string() } } else { let rev_num = patch.revisions.get(app.patch_revision_idx).map(|r| r.number).unwrap_or(1); format!("--- Diff r{} vs base ---", rev_num) }; lines.push(Line::styled( diff_mode, Style::default() .fg(Color::Green) .add_modifier(Modifier::BOLD), )); // Diff content if app.patch_diff.is_empty() { lines.push(Line::raw("(no diff available)")); } else { for l in app.patch_diff.lines() { let style = if l.starts_with('+') { Style::default().fg(Color::Green) } else if l.starts_with('-') { Style::default().fg(Color::Red) } else if l.starts_with("@@") { Style::default().fg(Color::Cyan) } else if l.starts_with("diff ") { Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD) } else { Style::default() }; lines.push(Line::styled(l.to_string(), style)); } } Text::from(lines) } fn render_footer(frame: &mut Frame, app: &App, area: Rect) { match app.input_mode { InputMode::Search => { @@ -449,7 +703,8 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) { let mode_hint = match app.mode { ViewMode::CommitList => " Esc:back", ViewMode::CommitDetail => " Esc:back j/k:scroll", ViewMode::Details => " c:events", ViewMode::PatchDetail => " Esc:back j/k:scroll [/]:revision d:interdiff", ViewMode::Details => " c:events p:patch", }; let filter_hint = match app.status_filter { StatusFilter::Open => "a:show all",