151e0a8f
Add patches list mode to TUI for direct patch browsing
a73x 2026-03-22 07:51
Patches can now be browsed directly via Shift-P toggle without needing an issue link. Selecting a patch and pressing Enter opens the full patch detail view with diff, reusing existing infrastructure. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
diff --git a/src/tui/events.rs b/src/tui/events.rs index b095aea..520a77c 100644 --- a/src/tui/events.rs +++ b/src/tui/events.rs @@ -205,21 +205,13 @@ pub(crate) fn run_loop( 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); } open_patch_detail(app, repo, patch); } } KeyAction::OpenPatchDetailDirect(idx) => { let patch = app.visible_patches().get(idx).cloned().cloned(); if let Some(patch) = patch { open_patch_detail(app, repo, patch); } } KeyAction::OpenCommitBrowser => { @@ -276,6 +268,22 @@ fn generate_patch_diff_for( } } fn open_patch_detail(app: &mut App, repo: &Repository, patch: crate::state::PatchState) { 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; 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); } } fn regenerate_patch_diff(app: &mut App, repo: &Repository) { if let Some(ref patch) = app.current_patch { let diff = generate_patch_diff_for( diff --git a/src/tui/state.rs b/src/tui/state.rs index 1380918..d6508f3 100644 --- a/src/tui/state.rs +++ b/src/tui/state.rs @@ -2,7 +2,7 @@ use crossterm::event::{KeyCode, KeyModifiers}; use git2::{Oid, Repository}; use ratatui::widgets::ListState; use crate::state::{self, IssueState, IssueStatus, PatchState}; use crate::state::{self, IssueState, IssueStatus, PatchState, PatchStatus}; #[derive(Debug, PartialEq)] pub(crate) enum Pane { @@ -25,6 +25,7 @@ pub(crate) enum KeyAction { Reload, OpenCommitBrowser, OpenPatchDetail, OpenPatchDetailDirect(usize), // index into visible_patches } #[derive(Debug, PartialEq, Clone, Copy)] @@ -52,6 +53,12 @@ impl StatusFilter { } } #[derive(Debug, PartialEq, Clone, Copy)] pub(crate) enum ListMode { Issues, Patches, } #[derive(Debug, PartialEq)] pub(crate) enum InputMode { Normal, @@ -64,6 +71,8 @@ pub(crate) struct App { pub(crate) issues: Vec<IssueState>, pub(crate) patches: Vec<PatchState>, pub(crate) list_state: ListState, pub(crate) patch_list_state: ListState, pub(crate) list_mode: ListMode, pub(crate) scroll: u16, pub(crate) pane: Pane, pub(crate) mode: ViewMode, @@ -89,10 +98,16 @@ impl App { if !issues.is_empty() { list_state.select(Some(0)); } let mut patch_list_state = ListState::default(); if !patches.is_empty() { patch_list_state.select(Some(0)); } Self { issues, patches, list_state, patch_list_state, list_mode: ListMode::Issues, scroll: 0, pane: Pane::ItemList, mode: ViewMode::Details, @@ -133,8 +148,23 @@ impl App { .collect() } pub(crate) fn visible_patches(&self) -> Vec<&PatchState> { self.patches .iter() .filter(|p| match self.status_filter { StatusFilter::Open => p.status == PatchStatus::Open, StatusFilter::Closed => p.status == PatchStatus::Closed || p.status == PatchStatus::Merged, StatusFilter::All => true, }) .filter(|p| self.matches_search(&p.title)) .collect() } pub(crate) fn visible_count(&self) -> usize { self.visible_issues().len() match self.list_mode { ListMode::Issues => self.visible_issues().len(), ListMode::Patches => self.visible_patches().len(), } } pub(crate) fn move_selection(&mut self, delta: i32) { @@ -142,13 +172,17 @@ impl App { if len == 0 { return; } let current = self.list_state.selected().unwrap_or(0); let state = match self.list_mode { ListMode::Issues => &mut self.list_state, ListMode::Patches => &mut self.patch_list_state, }; let current = 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)); state.select(Some(new)); self.scroll = 0; } @@ -168,6 +202,7 @@ impl App { match code { KeyCode::Esc => { self.mode = ViewMode::Details; self.pane = Pane::ItemList; self.current_patch = None; self.patch_diff.clear(); self.patch_scroll = 0; @@ -301,17 +336,34 @@ impl App { match code { KeyCode::Char('q') | KeyCode::Esc => KeyAction::Quit, KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => KeyAction::Quit, KeyCode::Char('P') => { // Toggle between Issues and Patches list mode self.list_mode = match self.list_mode { ListMode::Issues => ListMode::Patches, ListMode::Patches => ListMode::Issues, }; self.scroll = 0; self.pane = Pane::ItemList; self.mode = ViewMode::Details; KeyAction::Continue } 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() { if self.pane == Pane::Detail && self.list_mode == ListMode::Issues && self.list_state.selected().is_some() { KeyAction::OpenCommitBrowser } else { 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() { // Open patch detail: only when in detail pane with a linked patch (issues mode) if self.pane == Pane::Detail && self.list_mode == ListMode::Issues && self.linked_patch_for_selected().is_some() { KeyAction::OpenPatchDetail } else { KeyAction::Continue @@ -342,6 +394,15 @@ impl App { KeyAction::Continue } KeyCode::Tab | KeyCode::Enter => { // In Patches list mode, entering detail pane opens patch detail directly if self.list_mode == ListMode::Patches && self.pane == Pane::ItemList { if let Some(idx) = self.patch_list_state.selected() { let visible_len = self.visible_patches().len(); if idx < visible_len { return KeyAction::OpenPatchDetailDirect(idx); } } } self.pane = match self.pane { Pane::ItemList => Pane::Detail, Pane::Detail => Pane::ItemList, @@ -351,8 +412,11 @@ impl App { 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 }); let state = match self.list_mode { ListMode::Issues => &mut self.list_state, ListMode::Patches => &mut self.patch_list_state, }; state.select(if count > 0 { Some(0) } else { None }); KeyAction::Continue } KeyCode::Char('r') => KeyAction::Reload, @@ -378,11 +442,23 @@ impl App { if let Ok(patches) = state::list_patches(repo) { self.patches = patches; } let visible_len = self.visible_count(); // Clamp issue list selection let issue_len = self.visible_issues().len(); if let Some(sel) = self.list_state.selected() { if sel >= visible_len { self.list_state.select(if visible_len > 0 { Some(visible_len - 1) if sel >= issue_len { self.list_state.select(if issue_len > 0 { Some(issue_len - 1) } else { None }); } } // Clamp patch list selection let patch_len = self.visible_patches().len(); if let Some(sel) = self.patch_list_state.selected() { if sel >= patch_len { self.patch_list_state.select(if patch_len > 0 { Some(patch_len - 1) } else { None }); diff --git a/src/tui/widgets.rs b/src/tui/widgets.rs index 40079a1..096c80c 100644 --- a/src/tui/widgets.rs +++ b/src/tui/widgets.rs @@ -5,7 +5,7 @@ use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph, Wrap}; use crate::event::{Action, ReviewVerdict}; use crate::state::{IssueState, IssueStatus, PatchState, PatchStatus}; use super::state::{App, InputMode, Pane, StatusFilter, ViewMode}; use super::state::{App, InputMode, ListMode, Pane, StatusFilter, ViewMode}; pub(crate) fn action_type_label(action: &Action) -> &str { match action { @@ -146,39 +146,80 @@ fn render_list(frame: &mut Frame, app: &mut App, area: Rect) { 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); match app.list_mode { ListMode::Issues => { 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); } ListMode::Patches => { let visible = app.visible_patches(); let items: Vec<ListItem> = visible .iter() .map(|p| { let status = match p.status { PatchStatus::Open => "open", PatchStatus::Closed => "closed", PatchStatus::Merged => "merged", }; let style = match p.status { PatchStatus::Open => Style::default().fg(Color::Green), PatchStatus::Closed => Style::default().fg(Color::Red), PatchStatus::Merged => Style::default().fg(Color::Cyan), }; ListItem::new(format!("{:.8} {:6} {}", p.id, status, p.title)).style(style) }) .collect(); let title = format!("Patches ({})", 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.patch_list_state); } } } fn render_detail(frame: &mut Frame, app: &mut App, area: Rect) { @@ -260,24 +301,48 @@ fn render_detail(frame: &mut Frame, app: &mut App, area: Rect) { 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."), }; match app.list_mode { ListMode::Issues => { 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 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)); let para = Paragraph::new(content) .block(block) .wrap(Wrap { trim: false }) .scroll((app.scroll, 0)); frame.render_widget(para, area); frame.render_widget(para, area); } ListMode::Patches => { let visible = app.visible_patches(); let selected_idx = app.patch_list_state.selected().unwrap_or(0); let content: Text = match visible.get(selected_idx) { Some(patch) => build_patch_summary(patch), None => Text::raw("No patches for current filter."), }; let block = Block::default() .borders(Borders::ALL) .title("Patch 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> { @@ -409,6 +474,75 @@ fn build_issue_detail(issue: &IssueState, patches: &[PatchState]) -> Text<'stati Text::from(lines) } fn build_patch_summary(patch: &PatchState) -> Text<'static> { 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)), ])); } if !patch.body.is_empty() { lines.push(Line::raw("")); for l in patch.body.lines() { lines.push(Line::raw(l.to_string())); } } lines.push(Line::raw("")); lines.push(Line::styled( "Press Enter to view full detail with diff", Style::default().fg(Color::DarkGray), )); Text::from(lines) } fn build_patch_detail_text(app: &App) -> Text<'static> { let patch = match &app.current_patch { Some(p) => p, @@ -700,21 +834,30 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) { return; } let mode_hint = match app.mode { ViewMode::CommitList => " Esc:back", ViewMode::CommitDetail => " Esc:back j/k:scroll", 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", StatusFilter::All => "a:closed", StatusFilter::Closed => "a:open only", let text = match app.mode { ViewMode::CommitList => " j/k:navigate Enter:detail Esc:back q:quit".to_string(), ViewMode::CommitDetail => " j/k:scroll Esc:back q:quit".to_string(), ViewMode::PatchDetail => " j/k:scroll Esc:back [/]:revision d:interdiff q:quit".to_string(), ViewMode::Details => { let list_hint = match app.list_mode { ListMode::Issues => "P:patches", ListMode::Patches => "P:issues", }; let filter_hint = match app.status_filter { StatusFilter::Open => "a:show all", StatusFilter::All => "a:closed", StatusFilter::Closed => "a:open only", }; let mode_hint = match app.list_mode { ListMode::Issues => " c:events p:patch", ListMode::Patches => " Enter:view patch", }; format!( " j/k:navigate Tab:pane {} {}{} /:search r:refresh q:quit", list_hint, filter_hint, mode_hint ) } }; 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); }