3bbd878e
Remove Patches tab from TUI dashboard
a73x 2026-03-21 14:29
Simplify TUI to issues-only view. Patch CLI commands remain available. Patches data still loaded for cross-referencing in issue detail. Removes: Tab enum, ViewMode::Diff, PatchBranchInfo, generate_diff, build_patch_detail, colorize_diff, diff/branch caching, tab switching (1/2 keys), diff toggle (d key), follow_link (g key), and associated tests. Net reduction ~820 lines. Fixes: ec3e4d42 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
diff --git a/src/tui.rs b/src/tui.rs index 9af74f2..90b04f8 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -1,4 +1,3 @@ use std::collections::HashMap; use std::io::{self, stdout}; use std::time::Duration; @@ -7,7 +6,7 @@ use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen}; use crossterm::ExecutableCommand; use git2::{Oid, Repository}; use ratatui::prelude::*; use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Tabs, Wrap}; use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap}; use crate::error::Error; use crate::event::Action; @@ -20,16 +19,9 @@ enum Pane { Detail, } #[derive(Debug, PartialEq, Clone, Copy)] enum Tab { Issues, Patches, } #[derive(Debug, PartialEq)] enum ViewMode { Details, Diff, CommitList, CommitDetail, } @@ -75,20 +67,10 @@ enum InputMode { CreateBody, } /// Cached staleness info for a patch. #[derive(Clone)] struct PatchBranchInfo { staleness: Option<(usize, usize)>, branch_exists: bool, } struct App { tab: Tab, issues: Vec<IssueState>, patches: Vec<PatchState>, list_state: ListState, diff_cache: HashMap<String, String>, branch_info_cache: HashMap<String, PatchBranchInfo>, scroll: u16, pane: Pane, mode: ViewMode, @@ -109,12 +91,9 @@ impl App { list_state.select(Some(0)); } Self { tab: Tab::Issues, issues, patches, list_state, diff_cache: HashMap::new(), branch_info_cache: HashMap::new(), scroll: 0, pane: Pane::ItemList, mode: ViewMode::Details, @@ -150,25 +129,8 @@ impl App { .collect() } 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() } fn visible_count(&self) -> usize { match self.tab { Tab::Issues => self.visible_issues().len(), Tab::Patches => self.visible_patches().len(), } self.visible_issues().len() } fn move_selection(&mut self, delta: i32) { @@ -186,91 +148,6 @@ 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 follow_link(&mut self) -> bool { match self.tab { Tab::Issues => { // From an issue, jump to the first patch that fixes it let visible = self.visible_issues(); if let Some(idx) = self.list_state.selected() { if let Some(issue) = visible.get(idx) { let issue_id = issue.id.clone(); let target = self .patches .iter() .enumerate() .find(|(_, p)| p.fixes.as_deref() == Some(&issue_id)); if let Some((patch_idx, _)) = target { if self.status_filter != StatusFilter::All { let visible_patches = self.visible_patches(); if !visible_patches .iter() .any(|p| p.fixes.as_deref() == Some(&issue_id)) { self.status_filter = StatusFilter::All; } } let visible_patches = self.visible_patches(); if let Some(vi) = visible_patches .iter() .position(|p| p.id == self.patches[patch_idx].id) { self.tab = Tab::Patches; self.list_state.select(Some(vi)); self.scroll = 0; self.mode = ViewMode::Details; return true; } } } } false } Tab::Patches => { // From a patch, jump to the linked issue (fixes field) let visible = self.visible_patches(); if let Some(idx) = self.list_state.selected() { if let Some(patch) = visible.get(idx) { if let Some(ref fixes_id) = patch.fixes { let fixes_id = fixes_id.clone(); if self.status_filter != StatusFilter::All { let visible_issues = self.visible_issues(); if !visible_issues.iter().any(|i| i.id == fixes_id) { self.status_filter = StatusFilter::All; } } let visible_issues = self.visible_issues(); if let Some(vi) = visible_issues.iter().position(|i| i.id == fixes_id) { self.tab = Tab::Issues; self.list_state.select(Some(vi)); self.scroll = 0; self.mode = ViewMode::Details; return true; } } } } false } } } fn handle_key(&mut self, code: KeyCode, modifiers: KeyModifiers) -> KeyAction { // Handle CommitDetail mode first if self.mode == ViewMode::CommitDetail { @@ -346,7 +223,7 @@ impl App { } } // Normal Details/Diff mode handling // Normal Details mode handling match code { KeyCode::Char('q') | KeyCode::Esc => KeyAction::Quit, KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => KeyAction::Quit, @@ -358,14 +235,6 @@ impl App { KeyAction::Continue } } KeyCode::Char('1') => { self.switch_tab(Tab::Issues); KeyAction::Continue } KeyCode::Char('2') => { self.switch_tab(Tab::Patches); KeyAction::Continue } KeyCode::Char('j') | KeyCode::Down => { if self.pane == Pane::ItemList { self.move_selection(1); @@ -397,22 +266,6 @@ impl App { }; KeyAction::Continue } KeyCode::Char('d') => { if self.tab == Tab::Patches { match self.mode { ViewMode::Details => { self.mode = ViewMode::Diff; self.scroll = 0; } ViewMode::Diff => { self.mode = ViewMode::Details; self.scroll = 0; } _ => {} } } KeyAction::Continue } KeyCode::Char('a') => { self.status_filter = self.status_filter.next(); let count = self.visible_count(); @@ -427,25 +280,13 @@ impl App { fn selected_item_id(&self) -> Option<String> { let idx = self.list_state.selected()?; match self.tab { Tab::Issues => { let visible = self.visible_issues(); visible.get(idx).map(|i| i.id.clone()) } Tab::Patches => { let visible = self.visible_patches(); visible.get(idx).map(|p| p.id.clone()) } } let visible = self.visible_issues(); visible.get(idx).map(|i| i.id.clone()) } fn selected_ref_name(&self) -> Option<String> { let id = self.selected_item_id()?; let prefix = match self.tab { Tab::Issues => "refs/collab/issues", Tab::Patches => "refs/collab/patches", }; Some(format!("{}/{}", prefix, id)) Some(format!("refs/collab/issues/{}", id)) } fn reload(&mut self, repo: &Repository) { @@ -490,71 +331,6 @@ fn action_type_label(action: &Action) -> &str { } } fn generate_diff(repo: &Repository, patch: &PatchState) -> String { let result = (|| -> Result<String, Error> { let head_oid = patch.resolve_head(repo)?; let head_commit = repo.find_commit(head_oid) .map_err(|e| Error::Cmd(format!("bad head ref: {}", e)))?; let head_tree = head_commit.tree()?; let base_ref = format!("refs/heads/{}", patch.base_ref); let base_tree = if let Ok(base_oid) = repo.refname_to_id(&base_ref) { if let Ok(merge_base_oid) = repo.merge_base(base_oid, head_oid) { let merge_base_commit = repo.find_commit(merge_base_oid)?; Some(merge_base_commit.tree()?) } else { let base_commit = repo.find_commit(base_oid)?; Some(base_commit.tree()?) } } else { None }; let diff = repo.diff_tree_to_tree(base_tree.as_ref(), Some(&head_tree), None)?; let mut output = String::new(); let mut lines = 0usize; diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| { if lines >= 5000 { return false; } let prefix = match line.origin() { '+' => "+", '-' => "-", ' ' => " ", 'H' | 'F' => "", _ => "", }; if !prefix.is_empty() || matches!(line.origin(), 'H' | 'F') { output.push_str(prefix); } if let Ok(content) = std::str::from_utf8(line.content()) { output.push_str(content); } lines += 1; true })?; if lines >= 5000 { output.push_str("\n[truncated at 5000 lines]"); } Ok(output) })(); match result { Ok(diff) => { if diff.is_empty() { "No diff available (commits may be identical)".to_string() } else { diff } } Err(e) => format!("Diff unavailable: {}", e), } } fn format_event_detail(oid: &Oid, event: &crate::event::Event) -> String { let short_oid = &oid.to_string()[..7]; let action_label = action_type_label(&event.action); @@ -665,52 +441,6 @@ fn run_loop( repo: &Repository, ) -> Result<(), Error> { loop { // Cache diff and branch info for selected patch if needed if app.tab == Tab::Patches { if let Some(idx) = app.list_state.selected() { // Collect info we need without holding the borrow let patch_data = { let visible = app.visible_patches(); visible.get(idx).map(|patch| { let id = patch.id.clone(); let needs_branch_info = !app.branch_info_cache.contains_key(&id); let needs_diff = app.mode == ViewMode::Diff && !app.diff_cache.contains_key(&id); let branch_info = if needs_branch_info { Some({ let branch_exists = patch.resolve_head(repo).is_ok(); let staleness = if branch_exists { patch.staleness(repo).ok() } else { None }; PatchBranchInfo { staleness, branch_exists, } }) } else { None }; let diff = if needs_diff { Some(generate_diff(repo, patch)) } else { None }; (id, branch_info, diff) }) }; if let Some((id, branch_info, diff)) = patch_data { if let Some(info) = branch_info { app.branch_info_cache.insert(id.clone(), info); } if let Some(d) = diff { app.diff_cache.insert(id, d); } } } } terminal.draw(|frame| ui(frame, app))?; if event::poll(Duration::from_millis(100))? { @@ -824,7 +554,7 @@ fn run_loop( // Handle keys that need repo access or are run_loop-specific // before delegating to handle_key if app.mode == ViewMode::Details || app.mode == ViewMode::Diff { if app.mode == ViewMode::Details { match key.code { KeyCode::Char('/') => { app.input_mode = InputMode::Search; @@ -832,48 +562,29 @@ fn run_loop( continue; } KeyCode::Char('n') => { if app.tab != Tab::Issues { app.switch_tab(Tab::Issues); } app.input_mode = InputMode::CreateTitle; app.input_buf.clear(); app.create_title.clear(); continue; } KeyCode::Char('g') => { if !app.follow_link() { app.status_msg = Some("No linked item to follow".to_string()); } continue; } KeyCode::Char('o') => { // Check out the relevant commit for local browsing let checkout_target = match app.tab { Tab::Patches => { let visible = app.visible_patches(); app.list_state .selected() .and_then(|idx| visible.get(idx)) .map(|p| p.branch.clone()) } Tab::Issues => { // Find linked patch's branch, or fall back to closing commit let visible = app.visible_issues(); app.list_state .selected() .and_then(|idx| visible.get(idx)) .and_then(|issue| { // Try linked patch first app.patches .iter() .find(|p| p.fixes.as_deref() == Some(&issue.id)) .map(|p| p.branch.clone()) // Fall back to closing commit .or_else(|| { issue.closed_by.map(|oid| oid.to_string()) }) }) } let checkout_target = { let visible = app.visible_issues(); app.list_state .selected() .and_then(|idx| visible.get(idx)) .and_then(|issue| { // Try linked patch first app.patches .iter() .find(|p| p.fixes.as_deref() == Some(&issue.id)) .map(|p| p.branch.clone()) // Fall back to closing commit .or_else(|| { issue.closed_by.map(|oid| oid.to_string()) }) }) }; if let Some(head) = checkout_target { // Exit TUI, checkout, and return @@ -938,31 +649,13 @@ fn ui(frame: &mut Frame, app: &mut App) { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(1), Constraint::Min(1), Constraint::Length(1), ]) .split(frame.area()); 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 main_area = chunks[0]; let footer_area = chunks[1]; let panes = Layout::default() .direction(Direction::Horizontal) @@ -981,80 +674,39 @@ fn render_list(frame: &mut Frame, app: &mut App, area: Rect) { Style::default().fg(Color::DarkGray) }; 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 = 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); } 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 = 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.list_state); } } 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); } fn render_detail(frame: &mut Frame, app: &mut App, area: Rect) { @@ -1121,49 +773,16 @@ fn render_detail(frame: &mut Frame, app: &mut App, area: Rect) { return; } let title = match (&app.tab, &app.mode) { (Tab::Issues, _) => "Issue Details", (Tab::Patches, ViewMode::Details) => "Patch Details", (Tab::Patches, ViewMode::Diff) => "Diff", _ => "Details", }; 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, &app.patches), None => Text::raw("No matches for current filter."), } } 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 => { let branch_info = app.branch_info_cache.get(&patch.id).cloned(); build_patch_detail(patch, branch_info.as_ref()) } ViewMode::Diff => { let diff_text = app .diff_cache .get(&patch.id) .map(|s| s.as_str()) .unwrap_or("Loading..."); colorize_diff(diff_text, &patch.inline_comments) } _ => Text::raw(""), }, None => Text::raw("No matches for current filter."), } } 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(title) .title("Issue Details") .border_style(border_style); let para = Paragraph::new(content) @@ -1303,301 +922,6 @@ fn build_issue_detail(issue: &IssueState, patches: &[PatchState]) -> Text<'stati Text::from(lines) } fn build_patch_detail(patch: &PatchState, branch_info: Option<&PatchBranchInfo>) -> Text<'static> { let status = match patch.status { PatchStatus::Open => "open", PatchStatus::Closed => "closed", PatchStatus::Merged => "merged", }; 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(format!(" [{}]", status)), ]), 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)), ]), ]; if let Some(info) = branch_info { if info.branch_exists { lines.push(Line::from(vec![ Span::styled("Branch: ", Style::default().fg(Color::DarkGray)), Span::styled( patch.branch.clone(), Style::default().fg(Color::Cyan), ), Span::raw(format!(" -> {}", patch.base_ref)), ])); if let Some((ahead, behind)) = info.staleness { let freshness = if behind == 0 { "up-to-date" } else { "outdated" }; lines.push(Line::from(vec![ Span::styled("Commits: ", Style::default().fg(Color::DarkGray)), Span::raw(format!("{} ahead, {} behind ({})", ahead, behind, freshness)), ])); } } else { lines.push(Line::from(vec![ Span::styled("Branch: ", Style::default().fg(Color::DarkGray)), Span::styled( patch.branch.clone(), Style::default().fg(Color::Red), ), Span::styled(" (not found)", Style::default().fg(Color::Red)), ])); } } else { lines.push(Line::from(vec![ Span::styled("Branch: ", Style::default().fg(Color::DarkGray)), Span::raw(patch.branch.clone()), Span::raw(format!(" -> {}", patch.base_ref)), ])); } lines.push(Line::from(vec![ Span::styled("Created: ", Style::default().fg(Color::DarkGray)), Span::raw(patch.created_at.clone()), ])); if let Some(ref fixes) = patch.fixes { lines.push(Line::from(vec![ Span::styled("Fixes: ", Style::default().fg(Color::DarkGray)), Span::styled( format!("{:.8}", fixes), Style::default().fg(Color::Yellow), ), ])); } if !patch.body.is_empty() { lines.push(Line::raw("")); for l in patch.body.lines() { lines.push(Line::raw(l.to_string())); } } if !patch.reviews.is_empty() { lines.push(Line::raw("")); lines.push(Line::styled( "--- Reviews ---", Style::default() .fg(Color::Magenta) .add_modifier(Modifier::BOLD), )); for r in &patch.reviews { lines.push(Line::raw("")); let verdict_style = match r.verdict { crate::event::ReviewVerdict::Approve => Style::default().fg(Color::Green), crate::event::ReviewVerdict::RequestChanges => Style::default().fg(Color::Red), crate::event::ReviewVerdict::Comment => Style::default().fg(Color::Yellow), }; lines.push(Line::from(vec![ Span::raw(format!("{} ", r.author.name)), Span::styled(format!("({:?})", r.verdict), verdict_style), Span::styled( format!(" - {}", r.timestamp), Style::default().fg(Color::DarkGray), ), ])); for l in r.body.lines() { lines.push(Line::raw(format!(" {}", l))); } } } if !patch.inline_comments.is_empty() { lines.push(Line::raw("")); lines.push(Line::styled( "--- Inline Comments ---", Style::default() .fg(Color::Magenta) .add_modifier(Modifier::BOLD), )); for c in &patch.inline_comments { lines.push(Line::raw("")); lines.push(Line::from(vec![ Span::styled( format!("{}:{}", c.file, c.line), Style::default().fg(Color::Cyan), ), Span::raw(format!(" - {} ", c.author.name)), Span::styled( format!("({})", c.timestamp), Style::default().fg(Color::DarkGray), ), ])); for l in c.body.lines() { lines.push(Line::raw(format!(" {}", l))); } } } 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))); } } } Text::from(lines) } fn colorize_diff(diff: &str, inline_comments: &[state::InlineComment]) -> Text<'static> { // Build a lookup: (file, line) -> vec of comments let mut comment_map: HashMap<(String, u32), Vec<&state::InlineComment>> = HashMap::new(); for c in inline_comments { comment_map .entry((c.file.clone(), c.line)) .or_default() .push(c); } let mut lines: Vec<Line> = Vec::new(); let mut current_file = String::new(); let mut current_new_line: u32 = 0; for diff_line in diff.lines() { // Track which file we're in from +++ headers if let Some(file) = diff_line.strip_prefix("+++ b/") { current_file = file.to_string(); } else if diff_line.starts_with("+++ ") { // +++ /dev/null or similar current_file.clear(); } // Track line numbers from @@ hunk headers if diff_line.starts_with("@@") { // Parse @@ -old,len +new,len @@ if let Some(plus_part) = diff_line.split('+').nth(1) { if let Some(line_str) = plus_part.split(',').next().or(plus_part.split(' ').next()) { current_new_line = line_str.parse().unwrap_or(0); } } } // Colorize the diff line let style = if diff_line.starts_with('+') && !diff_line.starts_with("+++") { Style::default().fg(Color::Green) } else if diff_line.starts_with('-') && !diff_line.starts_with("---") { Style::default().fg(Color::Red) } else if diff_line.starts_with("@@") { Style::default().fg(Color::Cyan) } else if diff_line.starts_with("diff ") || diff_line.starts_with("index ") { Style::default().fg(Color::DarkGray) } else if diff_line.starts_with("+++") || diff_line.starts_with("---") { Style::default().fg(Color::Yellow) } else { Style::default() }; lines.push(Line::styled(diff_line.to_string(), style)); // After rendering this line, check for inline comments at current position if !current_file.is_empty() && current_new_line > 0 && (diff_line.starts_with('+') || diff_line.starts_with(' ') || diff_line.starts_with("@@")) { if let Some(comments) = comment_map.get(&(current_file.clone(), current_new_line)) { for c in comments { lines.push(Line::styled( format!(" ┌─ {} ({})", c.author.name, c.timestamp), Style::default() .fg(Color::Magenta) .add_modifier(Modifier::BOLD), )); for body_line in c.body.lines() { lines.push(Line::styled( format!(" │ {}", body_line), Style::default().fg(Color::Magenta), )); } lines.push(Line::styled( " └─".to_string(), Style::default().fg(Color::Magenta), )); } } } // Advance line counter for added/context lines (not removed or header) if (diff_line.starts_with('+') && !diff_line.starts_with("+++")) || diff_line.starts_with(' ') { current_new_line += 1; } } // Show any inline comments for files not covered by the diff let files_in_diff: std::collections::HashSet<&str> = diff .lines() .filter_map(|l| l.strip_prefix("+++ b/")) .collect(); let mut orphan_comments: Vec<&state::InlineComment> = inline_comments .iter() .filter(|c| !files_in_diff.contains(c.file.as_str())) .collect(); if !orphan_comments.is_empty() { orphan_comments.sort_by(|a, b| (&a.file, a.line).cmp(&(&b.file, b.line))); lines.push(Line::raw("")); lines.push(Line::styled( "--- Comments on files not in diff ---", Style::default() .fg(Color::Magenta) .add_modifier(Modifier::BOLD), )); for c in orphan_comments { lines.push(Line::styled( format!( " {}:{} - {} ({}):", c.file, c.line, c.author.name, c.timestamp ), Style::default().fg(Color::Magenta), )); for body_line in c.body.lines() { lines.push(Line::styled( format!(" {}", body_line), Style::default().fg(Color::Magenta), )); } } } Text::from(lines) } fn render_footer(frame: &mut Frame, app: &App, area: Rect) { match app.input_mode { InputMode::Search => { @@ -1653,17 +977,7 @@ 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", _ => { if app.tab == Tab::Patches { match app.mode { ViewMode::Details => " d:diff c:events", ViewMode::Diff => " d:details c:events", _ => "", } } else { " c:events" } } ViewMode::Details => " c:events", }; let filter_hint = match app.status_filter { StatusFilter::Open => "a:show all", @@ -1671,7 +985,7 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) { StatusFilter::Closed => "a:open only", }; let text = format!( " 1:issues 2:patches j/k:navigate Tab:pane {}{} /:search n:new issue g:follow o:checkout r:refresh q:quit", " 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)); @@ -1748,17 +1062,6 @@ mod tests { assert_eq!(visible[0].title, "Fix login bug"); } // T011: visible_patches filters by search_query (case-insensitive) #[test] fn test_visible_patches_text_filter() { let mut app = test_app(); app.status_filter = StatusFilter::All; app.search_query = "DASHBOARD".into(); let visible = app.visible_patches(); assert_eq!(visible.len(), 1); assert_eq!(visible[0].title, "Dashboard patch"); } // T012: visible_issues returns all status-matching items when search_query is empty #[test] fn test_visible_issues_no_text_filter() { @@ -1787,17 +1090,6 @@ mod tests { assert_eq!(visible[0].title, "Old closed issue"); } // T021: visible_patches returns closed AND merged when status_filter is Closed #[test] fn test_visible_patches_closed_filter() { let mut app = test_app(); app.status_filter = StatusFilter::Closed; let visible = app.visible_patches(); assert_eq!(visible.len(), 2); assert!(visible.iter().any(|p| p.status == PatchStatus::Closed)); assert!(visible.iter().any(|p| p.status == PatchStatus::Merged)); } // T025: combined status + text filter #[test] fn test_combined_filters() { @@ -2387,35 +1679,6 @@ mod tests { } #[test] fn test_handle_key_tab_switch() { let mut app = make_app(3, 3); app.handle_key(KeyCode::Char('2'), KeyModifiers::empty()); assert_eq!(app.tab, Tab::Patches); app.handle_key(KeyCode::Char('1'), KeyModifiers::empty()); assert_eq!(app.tab, Tab::Issues); } #[test] fn test_handle_key_diff_toggle_patches() { let mut app = make_app(0, 3); app.switch_tab(Tab::Patches); assert_eq!(app.mode, ViewMode::Details); app.handle_key(KeyCode::Char('d'), KeyModifiers::empty()); assert_eq!(app.mode, ViewMode::Diff); app.handle_key(KeyCode::Char('d'), KeyModifiers::empty()); assert_eq!(app.mode, ViewMode::Details); } #[test] fn test_handle_key_diff_noop_issues() { let mut app = make_app(3, 0); assert_eq!(app.tab, Tab::Issues); assert_eq!(app.mode, ViewMode::Details); app.handle_key(KeyCode::Char('d'), KeyModifiers::empty()); assert_eq!(app.mode, ViewMode::Details); } #[test] fn test_handle_key_pane_toggle() { let mut app = make_app(3, 3); assert_eq!(app.pane, Pane::ItemList); @@ -2462,17 +1725,6 @@ mod tests { } #[test] fn test_selected_ref_name_patches() { let mut app = make_app(0, 3); app.switch_tab(Tab::Patches); let ref_name = app.selected_ref_name(); assert_eq!( ref_name, Some("refs/collab/patches/p0000000".to_string()) ); } #[test] fn test_selected_ref_name_none_when_empty() { let app = make_app(0, 0); assert_eq!(app.selected_ref_name(), None); @@ -2485,8 +1737,7 @@ mod tests { let mut app = make_app(3, 2); app.status_filter = StatusFilter::All; let buf = render_app(&mut app); assert_buffer_contains(&buf, "1:Issues"); assert_buffer_contains(&buf, "2:Patches"); assert_buffer_contains(&buf, "Issues"); assert_buffer_contains(&buf, "00000000"); assert_buffer_contains(&buf, "00000001"); assert_buffer_contains(&buf, "00000002"); @@ -2494,18 +1745,6 @@ mod tests { } #[test] fn test_render_patches_tab() { let mut app = make_app(2, 3); app.status_filter = StatusFilter::All; app.switch_tab(Tab::Patches); let buf = render_app(&mut app); assert_buffer_contains(&buf, "p0000000"); assert_buffer_contains(&buf, "p0000001"); assert_buffer_contains(&buf, "p0000002"); assert_buffer_contains(&buf, "Patch 0"); } #[test] fn test_render_empty_state() { let mut app = make_app(0, 0); let buf = render_app(&mut app);