a73x

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