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