a73x

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