a73x

31c2f300

Add issues tab to TUI dashboard

a73x   2026-03-20 20:08

Dashboard now has tabbed navigation: press 1 for Issues, 2 for
Patches. Issues view shows title, author, body, and comments with
the same two-pane layout. Diff toggle (d) only available on patches
tab. Tab bar displayed at top with highlight on active tab.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

diff --git a/src/tui.rs b/src/tui.rs
index 9963a7e..899c67b 100644
--- a/src/tui.rs
+++ b/src/tui.rs
@@ -7,17 +7,23 @@ use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen};
use crossterm::ExecutableCommand;
use git2::{DiffFormat, Oid, Repository};
use ratatui::prelude::*;
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap};
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Tabs, Wrap};

use crate::error::Error;
use crate::state::{self, PatchState, PatchStatus};
use crate::state::{self, IssueState, IssueStatus, PatchState, PatchStatus};

#[derive(PartialEq)]
enum Pane {
    PatchList,
    ItemList,
    Detail,
}

#[derive(PartialEq, Clone, Copy)]
enum Tab {
    Issues,
    Patches,
}

#[derive(PartialEq)]
enum ViewMode {
    Details,
@@ -25,6 +31,8 @@ enum ViewMode {
}

struct App {
    tab: Tab,
    issues: Vec<IssueState>,
    patches: Vec<PatchState>,
    list_state: ListState,
    diff_cache: HashMap<String, String>,
@@ -35,22 +43,46 @@ struct App {
}

impl App {
    fn new(patches: Vec<PatchState>) -> Self {
    fn new(issues: Vec<IssueState>, patches: Vec<PatchState>) -> Self {
        let mut list_state = ListState::default();
        if !patches.is_empty() {
        if !issues.is_empty() {
            list_state.select(Some(0));
        }
        Self {
            tab: Tab::Issues,
            issues,
            patches,
            list_state,
            diff_cache: HashMap::new(),
            scroll: 0,
            pane: Pane::PatchList,
            pane: Pane::ItemList,
            mode: ViewMode::Details,
            show_all: false,
        }
    }

    fn visible_issue_count(&self) -> usize {
        if self.show_all {
            self.issues.len()
        } else {
            self.issues
                .iter()
                .filter(|i| i.status == IssueStatus::Open)
                .count()
        }
    }

    fn visible_issues(&self) -> Vec<&IssueState> {
        if self.show_all {
            self.issues.iter().collect()
        } else {
            self.issues
                .iter()
                .filter(|i| i.status == IssueStatus::Open)
                .collect()
        }
    }

    fn visible_patches(&self) -> Vec<&PatchState> {
        if self.show_all {
            self.patches.iter().collect()
@@ -62,9 +94,15 @@ impl App {
        }
    }

    fn visible_count(&self) -> usize {
        match self.tab {
            Tab::Issues => self.visible_issue_count(),
            Tab::Patches => self.visible_patches().len(),
        }
    }

    fn move_selection(&mut self, delta: i32) {
        let visible = self.visible_patches();
        let len = visible.len();
        let len = self.visible_count();
        if len == 0 {
            return;
        }
@@ -78,18 +116,36 @@ impl App {
        self.scroll = 0;
    }

    fn switch_tab(&mut self, tab: Tab) {
        if self.tab == tab {
            return;
        }
        self.tab = tab;
        self.list_state.select(if self.visible_count() > 0 {
            Some(0)
        } else {
            None
        });
        self.scroll = 0;
        self.mode = ViewMode::Details;
        self.pane = Pane::ItemList;
    }

    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_patches().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)
                    } else {
                        None
                    });
                }
        }
        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
                });
            }
        }
    }
@@ -122,8 +178,7 @@ fn generate_diff(repo: &Repository, patch: &PatchState) -> String {
                '+' => "+",
                '-' => "-",
                ' ' => " ",
                'H' => "",
                'F' => "",
                'H' | 'F' => "",
                _ => "",
            };
            if !prefix.is_empty() || matches!(line.origin(), 'H' | 'F') {
@@ -156,9 +211,10 @@ fn generate_diff(repo: &Repository, patch: &PatchState) -> String {
}

pub fn run(repo: &Repository) -> Result<(), Error> {
    let issues = state::list_issues(repo)?;
    let patches = state::list_patches(repo)?;

    let mut app = App::new(patches);
    let mut app = App::new(issues, patches);

    terminal::enable_raw_mode()?;
    stdout().execute(EnterAlternateScreen)?;
@@ -179,9 +235,10 @@ fn run_loop(
) -> Result<(), Error> {
    loop {
        // Cache diff for selected patch if needed
        if app.mode == ViewMode::Diff {
        if app.tab == Tab::Patches && app.mode == ViewMode::Diff {
            if let Some(idx) = app.list_state.selected() {
                if let Some(patch) = app.patches.get(idx) {
                let visible = app.visible_patches();
                if let Some(patch) = visible.get(idx) {
                    if !app.diff_cache.contains_key(&patch.id) {
                        let id = patch.id.clone();
                        let diff = generate_diff(repo, patch);
@@ -200,15 +257,17 @@ fn run_loop(
                    KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
                        return Ok(())
                    }
                    KeyCode::Char('1') => app.switch_tab(Tab::Issues),
                    KeyCode::Char('2') => app.switch_tab(Tab::Patches),
                    KeyCode::Char('j') | KeyCode::Down => {
                        if app.pane == Pane::PatchList {
                        if app.pane == Pane::ItemList {
                            app.move_selection(1);
                        } else {
                            app.scroll = app.scroll.saturating_add(1);
                        }
                    }
                    KeyCode::Char('k') | KeyCode::Up => {
                        if app.pane == Pane::PatchList {
                        if app.pane == Pane::ItemList {
                            app.move_selection(-1);
                        } else {
                            app.scroll = app.scroll.saturating_sub(1);
@@ -218,20 +277,24 @@ fn run_loop(
                    KeyCode::PageUp => app.scroll = app.scroll.saturating_sub(20),
                    KeyCode::Tab | KeyCode::Enter => {
                        app.pane = match app.pane {
                            Pane::PatchList => Pane::Detail,
                            Pane::Detail => Pane::PatchList,
                            Pane::ItemList => Pane::Detail,
                            Pane::Detail => Pane::ItemList,
                        };
                    }
                    KeyCode::Char('d') => {
                        app.mode = match app.mode {
                            ViewMode::Details => ViewMode::Diff,
                            ViewMode::Diff => ViewMode::Details,
                        };
                        app.scroll = 0;
                        if app.tab == Tab::Patches {
                            app.mode = match app.mode {
                                ViewMode::Details => ViewMode::Diff,
                                ViewMode::Diff => ViewMode::Details,
                            };
                            app.scroll = 0;
                        }
                    }
                    KeyCode::Char('a') => {
                        app.show_all = !app.show_all;
                        app.list_state.select(Some(0));
                        let count = app.visible_count();
                        app.list_state
                            .select(if count > 0 { Some(0) } else { None });
                    }
                    KeyCode::Char('r') => {
                        app.reload(repo);
@@ -246,68 +309,132 @@ fn run_loop(
fn ui(frame: &mut Frame, app: &mut App) {
    let chunks = Layout::default()
        .direction(Direction::Vertical)
        .constraints([Constraint::Min(1), Constraint::Length(1)])
        .constraints([
            Constraint::Length(1),
            Constraint::Min(1),
            Constraint::Length(1),
        ])
        .split(frame.area());

    let main_area = chunks[0];
    let footer_area = chunks[1];
    let tab_area = chunks[0];
    let main_area = chunks[1];
    let footer_area = chunks[2];

    // Tab bar
    let tab_titles = vec!["1:Issues", "2:Patches"];
    let selected_tab = match app.tab {
        Tab::Issues => 0,
        Tab::Patches => 1,
    };
    let tabs = Tabs::new(tab_titles)
        .select(selected_tab)
        .highlight_style(
            Style::default()
                .fg(Color::Yellow)
                .add_modifier(Modifier::BOLD),
        )
        .divider(" | ");
    frame.render_widget(tabs, tab_area);

    let panes = Layout::default()
        .direction(Direction::Horizontal)
        .constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
        .constraints([Constraint::Percentage(35), Constraint::Percentage(65)])
        .split(main_area);

    render_patch_list(frame, app, panes[0]);
    render_list(frame, app, panes[0]);
    render_detail(frame, app, panes[1]);
    render_footer(frame, app, footer_area);
}

fn render_patch_list(frame: &mut Frame, app: &mut App, area: Rect) {
    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 border_style = if app.pane == Pane::PatchList {
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 title = if app.show_all {
        "Patches (all)"
    } else {
        "Patches (open)"
    };
    match app.tab {
        Tab::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 = if app.show_all {
                "Issues (all)"
            } else {
                "Issues (open)"
            };

    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("> ");
            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);
        }
        Tab::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 = if app.show_all {
                "Patches (all)"
            } else {
                "Patches (open)"
            };

    frame.render_stateful_widget(list, area, &mut app.list_state);
            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: &App, area: Rect) {
@@ -317,36 +444,38 @@ fn render_detail(frame: &mut Frame, app: &App, area: Rect) {
        Style::default().fg(Color::DarkGray)
    };

    let visible = app.visible_patches();
    let selected_idx = app.list_state.selected().unwrap_or(0);
    let patch = visible.get(selected_idx);

    let title = match app.mode {
        ViewMode::Details => "Details",
        ViewMode::Diff => "Diff",
    let title = match (&app.tab, &app.mode) {
        (Tab::Issues, _) => "Issue Details",
        (Tab::Patches, ViewMode::Details) => "Patch Details",
        (Tab::Patches, ViewMode::Diff) => "Diff",
    };

    if patch.is_none() {
        let block = Block::default()
            .borders(Borders::ALL)
            .title(title)
            .border_style(border_style);
        let para = Paragraph::new("No patches to display.").block(block);
        frame.render_widget(para, area);
        return;
    }

    let patch = patch.unwrap();

    let content: Text = match app.mode {
        ViewMode::Details => build_detail_text(patch),
        ViewMode::Diff => {
            let diff_text = app
                .diff_cache
                .get(&patch.id)
                .map(|s| s.as_str())
                .unwrap_or("Loading...");
            colorize_diff(diff_text)
    let content: Text = match app.tab {
        Tab::Issues => {
            let visible = app.visible_issues();
            let selected_idx = app.list_state.selected().unwrap_or(0);
            match visible.get(selected_idx) {
                Some(issue) => build_issue_detail(issue),
                None => Text::raw("No issues to display."),
            }
        }
        Tab::Patches => {
            let visible = app.visible_patches();
            let selected_idx = app.list_state.selected().unwrap_or(0);
            match visible.get(selected_idx) {
                Some(patch) => match app.mode {
                    ViewMode::Details => build_patch_detail(patch),
                    ViewMode::Diff => {
                        let diff_text = app
                            .diff_cache
                            .get(&patch.id)
                            .map(|s| s.as_str())
                            .unwrap_or("Loading...");
                        colorize_diff(diff_text)
                    }
                },
                None => Text::raw("No patches to display."),
            }
        }
    };

@@ -363,7 +492,74 @@ fn render_detail(frame: &mut Frame, app: &App, area: Rect) {
    frame.render_widget(para, area);
}

fn build_detail_text(patch: &PatchState) -> Text<'static> {
fn build_issue_detail(issue: &IssueState) -> 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.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 build_patch_detail(patch: &PatchState) -> Text<'static> {
    let status = match patch.status {
        PatchStatus::Open => "open",
        PatchStatus::Closed => "closed",
@@ -382,15 +578,15 @@ fn build_detail_text(patch: &PatchState) -> Text<'static> {
            Span::raw(format!(" [{}]", status)),
        ]),
        Line::from(vec![
            Span::styled("Title:  ", Style::default().fg(Color::DarkGray)),
            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::styled("Author:  ", Style::default().fg(Color::DarkGray)),
            Span::raw(format!("{} <{}>", patch.author.name, patch.author.email)),
        ]),
        Line::from(vec![
            Span::styled("Base:   ", Style::default().fg(Color::DarkGray)),
            Span::styled("Base:    ", Style::default().fg(Color::DarkGray)),
            Span::raw(patch.base_ref.clone()),
            Span::raw("  "),
            Span::styled("Head: ", Style::default().fg(Color::DarkGray)),
@@ -494,9 +690,13 @@ fn colorize_diff(diff: &str) -> Text<'static> {
}

fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
    let mode_hint = match app.mode {
        ViewMode::Details => "d:diff",
        ViewMode::Diff => "d:details",
    let mode_hint = if app.tab == Tab::Patches {
        match app.mode {
            ViewMode::Details => "  d:diff",
            ViewMode::Diff => "  d:details",
        }
    } else {
        ""
    };
    let filter_hint = if app.show_all {
        "a:open only"
@@ -504,8 +704,8 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
        "a:show all"
    };
    let text = format!(
        " j/k:navigate  Tab:switch pane  {}  {}  r:refresh  q:quit",
        mode_hint, filter_hint
        " 1:issues  2:patches  j/k:navigate  Tab:pane  {}{}  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);