Skip to main content

remendo/
ui.rs

1//! Widget rendering — the TEA `view` function.
2//!
3//! Renders the application state into a terminal frame.
4//! This module contains no state mutation — it is a pure
5//! function of `App` → visual output.
6
7use crate::app::{App, FocusPanel, InputMode, ListContent, SortColumn, SortDirection, ViewMode};
8use crate::config::theme::ColorPalette;
9use crate::models::{FindingCounts, PatchsetStatus};
10use ratatui::Frame;
11use ratatui::layout::{Constraint, Layout, Rect};
12use ratatui::style::{Style, Stylize};
13use ratatui::text::{Line, Span};
14use ratatui::widgets::{
15    Block, Borders, Cell, Clear, List, ListItem, ListState, Paragraph, Row, Table, TableState, Wrap,
16};
17
18/// Render the application state into the given frame.
19///
20/// This is the TEA `view` function — it reads `App` state
21/// and produces visual output. No mutation occurs.
22pub fn view(app: &App, frame: &mut Frame) {
23    let area = frame.area();
24    let palette = &app.config.theme.colors;
25
26    // No remotes configured — show setup instructions (full screen)
27    if app.config.remotes.is_empty() {
28        let block = Block::default()
29            .title(" remendo ".bold())
30            .borders(Borders::ALL)
31            .border_style(Style::default().fg(palette.border.color()));
32        let content = "No remotes configured.\n\n\
33             To get started:\n  \
34             1. cp config.example.toml ~/.config/remendo/config.toml\n  \
35             2. Edit the file and set your Sashiko instance URL\n  \
36             3. Restart remendo";
37        let paragraph = Paragraph::new(content).block(block);
38        frame.render_widget(paragraph, area);
39        return;
40    }
41
42    // Split layout: sidebar + main pane
43    let chunks = Layout::horizontal([Constraint::Length(24), Constraint::Min(40)]).split(area);
44
45    // --- Sidebar ---
46    render_sidebar(app, frame, chunks[0], palette);
47
48    // --- Main pane ---
49    match app.view_mode {
50        ViewMode::List => render_main_pane(app, frame, chunks[1], palette),
51        ViewMode::Loading => {
52            // Render the list underneath, then overlay the loading dialog
53            render_main_pane(app, frame, chunks[1], palette);
54            render_loading_dialog(app, frame, palette);
55        }
56        ViewMode::Detail => match app.list_content {
57            ListContent::Patchsets => render_detail_view(app, frame, chunks[1], palette),
58            ListContent::Messages => render_message_detail(app, frame, chunks[1], palette),
59        },
60    }
61
62    // --- Help overlay (rendered on top of everything) ---
63    if app.show_help {
64        render_help_overlay(app, frame, palette);
65    }
66}
67
68/// Return the border color for a panel based on whether it has focus.
69fn panel_border_color(
70    current_focus: FocusPanel,
71    panel: FocusPanel,
72    palette: &ColorPalette,
73) -> ratatui::style::Color {
74    if current_focus == panel {
75        palette.accent.color()
76    } else {
77        palette.border.color()
78    }
79}
80
81/// Render the remote/mailbox sidebar in the given area.
82fn render_sidebar(
83    app: &App,
84    frame: &mut Frame,
85    area: ratatui::layout::Rect,
86    palette: &ColorPalette,
87) {
88    use crate::app::SidebarSection;
89
90    let remote_count = app.config.remotes.len();
91    let remote_height = u16::try_from(remote_count + 2)
92        .unwrap_or(5)
93        .min(area.height / 2);
94
95    let sidebar_chunks =
96        Layout::vertical([Constraint::Length(remote_height), Constraint::Min(3)]).split(area);
97
98    let highlight_style = Style::default()
99        .bg(palette.selected_bg.color())
100        .fg(palette.selected_fg.color());
101
102    // --- Remotes section ---
103    let remotes_focused =
104        app.focus == FocusPanel::Sidebar && app.sidebar_section == SidebarSection::Remotes;
105    let remotes_border = if remotes_focused {
106        palette.accent.color()
107    } else {
108        palette.border.color()
109    };
110    let remotes_block = Block::default()
111        .title(format!(" Remotes ({remote_count}) ").bold())
112        .borders(Borders::ALL)
113        .border_style(Style::default().fg(remotes_border));
114
115    let remote_items: Vec<ListItem> = app
116        .config
117        .remotes
118        .iter()
119        .map(|r| ListItem::new(r.name.as_str()))
120        .collect();
121
122    let remotes_list = List::new(remote_items)
123        .block(remotes_block)
124        .highlight_style(highlight_style);
125
126    let mut remotes_state = ListState::default();
127    if remotes_focused {
128        remotes_state.select(Some(app.active_remote_index));
129    }
130    frame.render_stateful_widget(remotes_list, sidebar_chunks[0], &mut remotes_state);
131
132    // --- Mailing lists section ---
133    let lists_focused =
134        app.focus == FocusPanel::Sidebar && app.sidebar_section == SidebarSection::MailingLists;
135    let lists_border = if lists_focused {
136        palette.accent.color()
137    } else {
138        palette.border.color()
139    };
140    let list_count = app.mailing_lists.len();
141    let lists_block = Block::default()
142        .title(format!(" Lists ({list_count}) ").bold())
143        .borders(Borders::ALL)
144        .border_style(Style::default().fg(lists_border));
145
146    if app.mailing_lists.is_empty() {
147        let placeholder = Paragraph::new("(none)")
148            .style(Style::default().fg(palette.muted.color()))
149            .block(lists_block);
150        frame.render_widget(placeholder, sidebar_chunks[1]);
151    } else {
152        let mut list_items: Vec<ListItem> = vec![ListItem::new("All")];
153        for ml in &app.mailing_lists {
154            list_items.push(ListItem::new(ml.name.as_str()));
155        }
156
157        let ml_list = List::new(list_items)
158            .block(lists_block)
159            .highlight_style(highlight_style);
160
161        let mut ml_state = ListState::default();
162        if lists_focused {
163            ml_state.select(Some(app.sidebar_list_index));
164        }
165        frame.render_stateful_widget(ml_list, sidebar_chunks[1], &mut ml_state);
166    }
167}
168
169/// Build the title bar status string for the main pane.
170fn build_main_title(app: &App) -> String {
171    if let Some(ref err) = app.error_state {
172        return format!(" remendo | ERROR: {err} ");
173    }
174    let remote = if app.active_remote.is_empty() {
175        "(no remote)"
176    } else {
177        &app.active_remote
178    };
179    let (content_label, content_total, total_pages) = match app.list_content {
180        ListContent::Patchsets => (
181            "patchsets",
182            app.patchsets.total,
183            app.patchsets.total_pages(),
184        ),
185        ListContent::Messages => ("messages", app.messages.total, app.messages.total_pages()),
186    };
187    let page_indicator = if total_pages > 1 {
188        format!(" | page {}/{total_pages}", app.list_params.page)
189    } else {
190        String::new()
191    };
192    let search_indicator = app
193        .list_params
194        .search
195        .as_ref()
196        .map_or(String::new(), |q| format!(" | q: \"{q}\""));
197    let list_indicator = app
198        .list_params
199        .mailing_list
200        .as_ref()
201        .map_or(String::new(), |l| format!(" | list: {l}"));
202    let bookmark_indicator = if app.show_bookmarks_only {
203        " | [B] bookmarks"
204    } else {
205        ""
206    };
207    if let Some(ref stats) = app.stats {
208        format!(
209            " remendo | {remote} | v{} | {} pending | {} reviewing | {content_total} {content_label}{page_indicator}{search_indicator}{list_indicator}{bookmark_indicator} ",
210            stats.version, stats.pending, stats.reviewing
211        )
212    } else {
213        format!(
214            " remendo | {remote} | {content_total} {content_label}{page_indicator}{search_indicator}{list_indicator}{bookmark_indicator} "
215        )
216    }
217}
218
219/// Render the main patchset pane (table or placeholder) in the given area.
220fn render_main_pane(
221    app: &App,
222    frame: &mut Frame,
223    area: ratatui::layout::Rect,
224    palette: &ColorPalette,
225) {
226    let border_color = panel_border_color(app.focus, FocusPanel::PatchsetList, palette);
227    let status = build_main_title(app);
228
229    let block = Block::default()
230        .title(status.bold())
231        .borders(Borders::ALL)
232        .border_style(Style::default().fg(border_color));
233
234    // Empty list — show loading, error, or no-results placeholder
235    let items_empty = match app.list_content {
236        ListContent::Patchsets => app.patchsets.items.is_empty(),
237        ListContent::Messages => app.messages.items.is_empty(),
238    };
239    if items_empty {
240        let text = if app.error_state.is_some() {
241            "Error loading. Press Ctrl-r to retry.".to_string()
242        } else if let Some(ref q) = app.list_params.search {
243            format!("No results for \"{q}\"")
244        } else {
245            "Loading...".to_string()
246        };
247        let paragraph = Paragraph::new(text).block(block);
248        frame.render_widget(paragraph, area);
249        return;
250    }
251
252    // Branch rendering by list content type
253    if app.list_content == ListContent::Messages {
254        render_message_table(app, frame, area, block, palette);
255        return;
256    }
257
258    // Build table rows from patchsets
259    let rows: Vec<Row> = app
260        .patchsets
261        .items
262        .iter()
263        .filter(|ps| !app.show_bookmarks_only || app.bookmarks.contains(&app.active_remote, ps.id))
264        .map(|ps| build_patchset_row(app, ps, palette))
265        .collect();
266
267    let sort_header = |label: &str, col: SortColumn| -> String {
268        if app.sort_column == col {
269            match app.sort_direction {
270                SortDirection::Ascending => format!("{label} \u{25b2}"),
271                SortDirection::Descending => format!("{label} \u{25bc}"),
272            }
273        } else {
274            label.to_string()
275        }
276    };
277
278    let header = Row::new(vec![
279        Cell::from(""),
280        Cell::from(sort_header("Status", SortColumn::Status)),
281        Cell::from("Subject".to_string()),
282        Cell::from(sort_header("Author", SortColumn::Author)),
283        Cell::from(sort_header("Date", SortColumn::Date)),
284        Cell::from(sort_header("Findings", SortColumn::Findings)),
285        Cell::from("Subsystems".to_string()),
286    ])
287    .style(Style::default().fg(palette.accent.color()).bold());
288
289    let widths = [
290        Constraint::Length(3),
291        Constraint::Length(14),
292        Constraint::Min(30),
293        Constraint::Length(20),
294        Constraint::Length(10),
295        Constraint::Length(10),
296        Constraint::Length(18),
297    ];
298
299    let table = Table::new(rows, widths)
300        .header(header)
301        .block(block)
302        .row_highlight_style(
303            Style::default()
304                .bg(palette.selected_bg.color())
305                .fg(palette.selected_fg.color()),
306        );
307
308    let mut table_state = TableState::default();
309    table_state.select(Some(app.selected_index));
310
311    frame.render_stateful_widget(table, area, &mut table_state);
312
313    // Search bar at the bottom of the main pane
314    if app.input_mode == InputMode::Search {
315        let search_area = Rect::new(
316            area.x + 1,
317            area.y + area.height.saturating_sub(2),
318            area.width.saturating_sub(2),
319            1,
320        );
321        let search_text = format!("/{}", app.search_buffer);
322        frame.render_widget(
323            Paragraph::new(search_text).style(Style::default().fg(palette.accent.color())),
324            search_area,
325        );
326        #[allow(clippy::cast_possible_truncation)]
327        frame.set_cursor_position((search_area.x + 1 + app.search_cursor as u16, search_area.y));
328    }
329}
330
331/// Map a `PatchsetStatus` to a foreground color style.
332fn status_color(status: PatchsetStatus, palette: &ColorPalette) -> Style {
333    match status {
334        PatchsetStatus::Pending => Style::default().fg(palette.foreground.color()),
335        PatchsetStatus::InReview => Style::default().fg(palette.accent.color()),
336        PatchsetStatus::Reviewed => Style::default().fg(palette.success.color()),
337        PatchsetStatus::Failed | PatchsetStatus::FailedToApply => {
338            Style::default().fg(palette.error.color())
339        }
340        PatchsetStatus::Cancelled => Style::default().fg(palette.warning.color()),
341        PatchsetStatus::Incomplete | PatchsetStatus::Skipped | PatchsetStatus::Unknown => {
342            Style::default().fg(palette.muted.color())
343        }
344    }
345}
346
347/// Format a unix timestamp as `YYYY-MM-DD` without external crate dependencies.
348fn format_date(ts: Option<i64>) -> String {
349    let Some(ts) = ts else {
350        return String::from("—");
351    };
352
353    // Simple conversion from unix timestamp to YYYY-MM-DD.
354    // Uses the algorithm for days since epoch -> civil date.
355    let secs = ts;
356    let days = secs / 86400;
357    // Civil calendar conversion (Howard Hinnant's algorithm)
358    let z = days + 719_468;
359    let era = (if z >= 0 { z } else { z - 146_096 }) / 146_097;
360    let doe = z - era * 146_097;
361    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
362    let y = yoe + era * 400;
363    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
364    let mp = (5 * doy + 2) / 153;
365    let d = doy - (153 * mp + 2) / 5 + 1;
366    let m = if mp < 10 { mp + 3 } else { mp - 9 };
367    let y = if m <= 2 { y + 1 } else { y };
368
369    format!("{y:04}-{m:02}-{d:02}")
370}
371
372/// Format `FindingCounts` as compact severity indicators.
373///
374/// Non-zero severities are shown in descending order (e.g., `1C 2H`).
375/// High and critical counts use the error color.
376fn format_findings<'a>(findings: &FindingCounts, palette: &'a ColorPalette) -> Line<'a> {
377    if findings.is_empty() {
378        return Line::from("—").style(Style::default().fg(palette.muted.color()));
379    }
380
381    let mut spans: Vec<Span<'a>> = Vec::new();
382    let error_style = Style::default().fg(palette.error.color());
383    let warn_style = Style::default().fg(palette.warning.color());
384    let default_style = Style::default().fg(palette.muted.color());
385
386    if findings.critical > 0 {
387        if !spans.is_empty() {
388            spans.push(Span::raw(" "));
389        }
390        spans.push(Span::styled(format!("{}C", findings.critical), error_style));
391    }
392    if findings.high > 0 {
393        if !spans.is_empty() {
394            spans.push(Span::raw(" "));
395        }
396        spans.push(Span::styled(format!("{}H", findings.high), error_style));
397    }
398    if findings.medium > 0 {
399        if !spans.is_empty() {
400            spans.push(Span::raw(" "));
401        }
402        spans.push(Span::styled(format!("{}M", findings.medium), warn_style));
403    }
404    if findings.low > 0 {
405        if !spans.is_empty() {
406            spans.push(Span::raw(" "));
407        }
408        spans.push(Span::styled(format!("{}L", findings.low), default_style));
409    }
410
411    Line::from(spans)
412}
413
414/// Build a single table row for a patchset in the list view.
415fn build_patchset_row<'a>(
416    app: &'a App,
417    ps: &'a crate::models::Patchset,
418    palette: &'a ColorPalette,
419) -> Row<'a> {
420    let bookmark_cell = if app.bookmarks.contains(&app.active_remote, ps.id) {
421        Cell::from(" * ").style(Style::default().fg(palette.accent.color()))
422    } else {
423        Cell::from("   ")
424    };
425
426    let status_style = status_color(ps.status, palette);
427    let status_text = match (&ps.failed_reason, ps.status) {
428        (Some(reason), PatchsetStatus::Failed | PatchsetStatus::FailedToApply) => {
429            let mut text = format!("Failed: {reason}");
430            text.truncate(14);
431            text
432        }
433        _ => ps.status.to_string(),
434    };
435    let status_cell = Cell::from(status_text).style(status_style);
436    let subject_cell = Cell::from(ps.subject());
437    let author_cell = Cell::from(ps.author()).style(Style::default().fg(palette.muted.color()));
438    let date_cell =
439        Cell::from(format_date(ps.date)).style(Style::default().fg(palette.muted.color()));
440    let findings_cell = Cell::from(format_findings(&ps.findings, palette));
441    let subsystems_text = if ps.subsystems.is_empty() {
442        String::new()
443    } else {
444        ps.subsystems.join(", ")
445    };
446    let subsystems_cell =
447        Cell::from(subsystems_text).style(Style::default().fg(palette.muted.color()));
448
449    Row::new(vec![
450        bookmark_cell,
451        status_cell,
452        subject_cell,
453        author_cell,
454        date_cell,
455        findings_cell,
456        subsystems_cell,
457    ])
458}
459
460/// Compute a centered rectangle within the given area.
461fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
462    let w = width.min(area.width);
463    let h = height.min(area.height);
464    let x = area.x + (area.width.saturating_sub(w)) / 2;
465    let y = area.y + (area.height.saturating_sub(h)) / 2;
466    Rect::new(x, y, w, h)
467}
468
469/// Render the help overlay showing all keybinding mappings.
470/// Render a loading dialog showing the selected patchset being fetched.
471/// Render the message list table.
472fn render_message_table(
473    app: &App,
474    frame: &mut Frame,
475    area: Rect,
476    block: Block<'_>,
477    palette: &ColorPalette,
478) {
479    let rows: Vec<Row> = app
480        .messages
481        .items
482        .iter()
483        .map(|msg| {
484            Row::new(vec![
485                Cell::from(msg.subject.as_deref().unwrap_or("(no subject)")),
486                Cell::from(msg.author.as_deref().unwrap_or("(unknown)"))
487                    .style(Style::default().fg(palette.muted.color())),
488                Cell::from(format_date(msg.date)).style(Style::default().fg(palette.muted.color())),
489                Cell::from(msg.mailing_list.as_deref().unwrap_or(""))
490                    .style(Style::default().fg(palette.muted.color())),
491            ])
492        })
493        .collect();
494
495    let header = Row::new(vec![
496        Cell::from("Subject"),
497        Cell::from("Author"),
498        Cell::from("Date"),
499        Cell::from("List"),
500    ])
501    .style(Style::default().fg(palette.accent.color()).bold());
502
503    let widths = [
504        Constraint::Min(30),
505        Constraint::Length(20),
506        Constraint::Length(10),
507        Constraint::Length(24),
508    ];
509
510    let table = Table::new(rows, widths)
511        .header(header)
512        .block(block)
513        .row_highlight_style(
514            Style::default()
515                .bg(palette.selected_bg.color())
516                .fg(palette.selected_fg.color()),
517        );
518
519    let mut table_state = TableState::default();
520    table_state.select(Some(app.selected_index));
521
522    frame.render_stateful_widget(table, area, &mut table_state);
523}
524
525/// Render the message detail view.
526/// Classify a raw unified diff line by its leading prefix.
527///
528/// Used for `EmailMessage.diff` content which is pure diff output.
529/// Context lines and unrecognized content render as `muted`.
530fn classify_diff_line(line: &str, palette: &ColorPalette) -> Style {
531    if line.starts_with("+++")
532        || line.starts_with("---")
533        || line.starts_with("diff ")
534        || line.starts_with("index ")
535    {
536        Style::default().fg(palette.foreground.color()).bold()
537    } else if line.starts_with("@@") {
538        Style::default().fg(palette.accent.color()).bold()
539    } else if line.starts_with('+') {
540        Style::default().fg(palette.success.color())
541    } else if line.starts_with('-') {
542        Style::default().fg(palette.error.color())
543    } else {
544        Style::default().fg(palette.muted.color())
545    }
546}
547
548/// Classify a line from a Sashiko inline review with context about
549/// whether we've seen quoted diff content yet.
550///
551/// Sashiko inline reviews have a three-part structure:
552///
553/// 1. **Patch metadata/summary** — unquoted lines *before* any `>`-quoted
554///    diff (commit hash, Author:, Subject:, description, Links).
555///    Styled as `muted`.
556///
557/// 2. **Quoted diff** — lines with `>` prefix containing patch content.
558///    Classified by diff role (green/red/cyan/bold/muted).
559///
560/// 3. **Reviewer commentary** — unquoted lines *after* the first `>`-quoted
561///    section. This is the AI review analysis. Styled as `foreground`.
562///
563/// The `seen_quoted` flag tracks whether any `>`-quoted line has been
564/// encountered. Before that, unquoted text is patch context. After,
565/// unquoted text is reviewer commentary.
566fn classify_review_line(line: &str, seen_quoted: bool, palette: &ColorPalette) -> Style {
567    if line.is_empty() {
568        return Style::default();
569    }
570
571    if line.starts_with('>') {
572        // Quoted content — strip quoting and classify as diff
573        let stripped = strip_review_quoting(line);
574        classify_diff_line(stripped, palette)
575    } else if seen_quoted {
576        // Unquoted after we've seen quoted content — reviewer commentary
577        Style::default().fg(palette.foreground.color())
578    } else {
579        // Unquoted before any quoted content — patch metadata/summary
580        Style::default().fg(palette.muted.color())
581    }
582}
583
584/// Strip leading `>` quoting prefixes from a review line.
585///
586/// Sashiko inline reviews quote patch content with `>` prefixes.
587/// Handles `"> "`, `">"`, and nested quoting like `">> "`.
588fn strip_review_quoting(line: &str) -> &str {
589    let mut s = line;
590    while s.starts_with('>') {
591        s = s.strip_prefix('>').unwrap_or(s);
592        // Consume one optional space after each >
593        s = s.strip_prefix(' ').unwrap_or(s);
594    }
595    s
596}
597
598/// Build content lines for the message detail view.
599///
600/// Pure data preparation — no frame rendering. Testable independently.
601fn message_detail_lines<'a>(
602    msg: &'a crate::models::EmailMessage,
603    palette: &'a ColorPalette,
604) -> Vec<Line<'a>> {
605    let mut lines: Vec<Line> = Vec::new();
606
607    // Header
608    lines.push(Line::from(vec![
609        Span::styled("From: ", Style::default().fg(palette.muted.color())),
610        Span::raw(msg.author.as_deref().unwrap_or("(unknown)")),
611    ]));
612    lines.push(Line::from(vec![
613        Span::styled("Date: ", Style::default().fg(palette.muted.color())),
614        Span::raw(format_date(msg.date)),
615    ]));
616    if let Some(ref to) = msg.to {
617        lines.push(Line::from(vec![
618            Span::styled("To: ", Style::default().fg(palette.muted.color())),
619            Span::raw(to.as_str()),
620        ]));
621    }
622    if let Some(ref cc) = msg.cc {
623        lines.push(Line::from(vec![
624            Span::styled("Cc: ", Style::default().fg(palette.muted.color())),
625            Span::raw(cc.as_str()),
626        ]));
627    }
628    if let Some(ref list) = msg.mailing_list {
629        lines.push(Line::from(vec![
630            Span::styled("List: ", Style::default().fg(palette.muted.color())),
631            Span::raw(list.as_str()),
632        ]));
633    }
634
635    lines.push(Line::raw(""));
636
637    // Body
638    if let Some(ref body) = msg.body {
639        for body_line in body.lines() {
640            lines.push(Line::raw(body_line));
641        }
642    } else {
643        lines.push(Line::styled(
644            "(no body)",
645            Style::default().fg(palette.muted.color()),
646        ));
647    }
648
649    // Diff section
650    if let Some(ref diff) = msg.diff {
651        lines.push(Line::raw(""));
652        lines.push(Line::styled(
653            "── Diff ──",
654            Style::default().fg(palette.accent.color()).bold(),
655        ));
656        for diff_line in diff.lines() {
657            let style = classify_diff_line(diff_line, palette);
658            lines.push(Line::styled(diff_line, style));
659        }
660    }
661
662    lines
663}
664
665fn render_message_detail(app: &App, frame: &mut Frame, area: Rect, palette: &ColorPalette) {
666    let border_color = panel_border_color(app.focus, FocusPanel::PatchsetList, palette);
667
668    let Some(ref msg) = app.selected_message else {
669        let block = Block::default()
670            .title(" Loading message... ".bold())
671            .borders(Borders::ALL)
672            .border_style(Style::default().fg(border_color));
673        frame.render_widget(Paragraph::new("").block(block), area);
674        return;
675    };
676
677    let subject = msg.subject.as_deref().unwrap_or("(no subject)");
678    let title = format!(" {subject} ");
679    let block = Block::default()
680        .title(title.bold())
681        .borders(Borders::ALL)
682        .border_style(Style::default().fg(border_color));
683
684    let lines = message_detail_lines(msg, palette);
685
686    let scroll_offset = u16::try_from(app.detail_scroll_offset).unwrap_or(u16::MAX);
687    let paragraph = Paragraph::new(lines)
688        .block(block)
689        .wrap(Wrap { trim: false })
690        .scroll((scroll_offset, 0));
691
692    frame.render_widget(paragraph, area);
693}
694
695fn render_loading_dialog(app: &App, frame: &mut Frame, palette: &ColorPalette) {
696    let Some(ref ctx) = app.loading_context else {
697        return;
698    };
699
700    let popup_width: u16 = 50;
701    let popup_height: u16 = 7;
702    let area = centered_rect(popup_width, popup_height, frame.area());
703
704    frame.render_widget(Clear, area);
705
706    let block = Block::default()
707        .title(" Loading Patchset ".bold())
708        .borders(Borders::ALL)
709        .border_style(Style::default().fg(palette.accent.color()));
710
711    let text = vec![
712        Line::from(""),
713        Line::from(vec![
714            Span::styled("  ID: ", Style::default().fg(palette.muted.color())),
715            Span::styled(
716                ctx.patchset_id.to_string(),
717                Style::default().fg(palette.accent.color()),
718            ),
719            Span::raw("  "),
720            Span::styled(
721                format!("[{}]", ctx.status),
722                Style::default().fg(palette.foreground.color()),
723            ),
724        ]),
725        Line::from(vec![
726            Span::styled("  ", Style::default()),
727            Span::raw(&ctx.subject),
728        ]),
729        Line::from(""),
730        Line::styled(
731            "  Loading detail...",
732            Style::default().fg(palette.muted.color()),
733        ),
734    ];
735
736    let paragraph = Paragraph::new(text).block(block);
737    frame.render_widget(paragraph, area);
738}
739
740fn render_help_overlay(app: &App, frame: &mut Frame, palette: &ColorPalette) {
741    // Collect and sort bindings by action label
742    let mut entries: Vec<_> = app
743        .config
744        .keybindings
745        .bindings
746        .iter()
747        .map(|(combo, action)| (combo.to_string(), action.label()))
748        .collect();
749    entries.sort_by(|a, b| a.1.cmp(b.1));
750
751    let popup_width: u16 = 38;
752    let popup_height = u16::try_from(entries.len() + 4).unwrap_or(24).min(40);
753    let area = centered_rect(popup_width, popup_height, frame.area());
754
755    frame.render_widget(Clear, area);
756
757    let rows: Vec<Row> = entries
758        .iter()
759        .map(|(key, label)| {
760            Row::new(vec![
761                Cell::from(key.as_str()).style(Style::default().fg(palette.accent.color())),
762                Cell::from(*label),
763            ])
764        })
765        .collect();
766
767    let widths = [Constraint::Length(12), Constraint::Min(18)];
768    let block = Block::default()
769        .title(" Keybindings ".bold())
770        .borders(Borders::ALL)
771        .border_style(Style::default().fg(palette.accent.color()));
772
773    let table = Table::new(rows, widths).block(block);
774    frame.render_widget(table, area);
775}
776
777/// Render the patchset detail view in the given area.
778fn render_detail_view(app: &App, frame: &mut Frame, area: Rect, palette: &ColorPalette) {
779    let border_color = panel_border_color(app.focus, FocusPanel::PatchsetList, palette);
780
781    let Some(ref detail) = app.selected_detail else {
782        let block = Block::default()
783            .title(" Loading detail... ".bold())
784            .borders(Borders::ALL)
785            .border_style(Style::default().fg(border_color));
786        frame.render_widget(Paragraph::new("").block(block), area);
787        return;
788    };
789
790    let subject = detail.subject.as_deref().unwrap_or("(no subject)");
791    let title = format!(" {subject} ");
792    let block = Block::default()
793        .title(title.bold())
794        .borders(Borders::ALL)
795        .border_style(Style::default().fg(border_color));
796
797    let mut lines: Vec<Line> = Vec::new();
798    detail_header_lines(detail, palette, &mut lines);
799    detail_patches_lines(detail, palette, &mut lines);
800    detail_thread_lines(detail, palette, &mut lines);
801
802    let scroll_offset = u16::try_from(app.detail_scroll_offset).unwrap_or(u16::MAX);
803    let paragraph = Paragraph::new(lines)
804        .block(block)
805        .wrap(Wrap { trim: false })
806        .scroll((scroll_offset, 0));
807
808    frame.render_widget(paragraph, area);
809}
810
811/// Build header lines for the detail view.
812fn detail_header_lines<'a>(
813    detail: &'a crate::models::PatchsetDetail,
814    palette: &'a ColorPalette,
815    lines: &mut Vec<Line<'a>>,
816) {
817    let status_style = status_color(detail.status, palette);
818    lines.push(Line::from(vec![
819        Span::styled(detail.status.to_string(), status_style),
820        Span::raw("  "),
821        Span::styled(
822            detail.author.as_deref().unwrap_or("(unknown)"),
823            Style::default().fg(palette.muted.color()),
824        ),
825        Span::raw("  "),
826        Span::styled(
827            format_date(detail.date),
828            Style::default().fg(palette.muted.color()),
829        ),
830    ]));
831
832    // Show failure reason if present
833    if let Some(ref reason) = detail.failed_reason {
834        lines.push(Line::styled(
835            format!("Failed: {reason}"),
836            Style::default().fg(palette.error.color()),
837        ));
838    }
839
840    if let (Some(total), Some(received)) = (detail.total_parts, detail.received_parts) {
841        let sub_text = if detail.subsystems.is_empty() {
842            String::new()
843        } else {
844            format!("   {}", detail.subsystems.join(", "))
845        };
846        lines.push(Line::from(vec![
847            Span::styled(
848                format!("Parts: {received}/{total}"),
849                Style::default().fg(palette.muted.color()),
850            ),
851            Span::styled(sub_text, Style::default().fg(palette.muted.color())),
852        ]));
853    }
854
855    if let Some(ref baseline) = detail.baseline {
856        let branch = baseline.branch.as_deref().unwrap_or("?");
857        let commit = baseline
858            .commit
859            .as_deref()
860            .map_or("?", |c| if c.len() > 12 { &c[..12] } else { c });
861        let has_logs = detail.baseline_logs.as_ref().is_some_and(|s| !s.is_empty());
862        let mut spans = vec![Span::styled(
863            format!("Baseline: {branch} @ {commit}"),
864            Style::default().fg(palette.info.color()),
865        )];
866        if has_logs {
867            spans.push(Span::styled(
868                "  [logs: L]",
869                Style::default().fg(palette.accent.color()),
870            ));
871        }
872        lines.push(Line::from(spans));
873    }
874
875    if let Some(ref model) = detail.model_name {
876        let provider = detail.provider.as_deref().unwrap_or("?");
877        lines.push(Line::styled(
878            format!("Model: {provider}/{model}"),
879            Style::default().fg(palette.muted.color()),
880        ));
881    }
882
883    lines.push(Line::raw(""));
884}
885
886/// Build patches + inline review lines for the detail view.
887fn detail_patches_lines<'a>(
888    detail: &'a crate::models::PatchsetDetail,
889    palette: &'a ColorPalette,
890    lines: &mut Vec<Line<'a>>,
891) {
892    let total_parts = detail.total_parts.unwrap_or(0);
893    lines.push(Line::styled(
894        format!("── Patches ({}) ──", detail.patches.len()),
895        Style::default().fg(palette.accent.color()).bold(),
896    ));
897
898    if detail.patches.is_empty() {
899        lines.push(Line::styled(
900            "(no patches)",
901            Style::default().fg(palette.muted.color()),
902        ));
903    } else {
904        for patch in &detail.patches {
905            let idx = patch.part_index.unwrap_or(0);
906            let status_str = patch
907                .status
908                .as_ref()
909                .map_or_else(|| "?".to_string(), ToString::to_string);
910            let patch_subject = patch.subject.as_deref().unwrap_or("(no subject)");
911            lines.push(Line::from(vec![
912                Span::styled(
913                    format!("{idx}/{total_parts}  "),
914                    Style::default().fg(palette.accent.color()),
915                ),
916                Span::styled(
917                    format!("[{status_str}]  "),
918                    Style::default().fg(palette.success.color()),
919                ),
920                Span::raw(patch_subject),
921            ]));
922
923            let review = detail.reviews.iter().find(|r| r.patch_id == patch.id);
924            if let Some(rev) = review {
925                if let Some(ref summary) = rev.summary {
926                    lines.push(Line::styled(
927                        format!("  Summary: {summary}"),
928                        Style::default().fg(palette.muted.color()),
929                    ));
930                }
931                if let Some(ref inline) = rev.inline_review {
932                    lines.push(Line::raw(""));
933                    let mut seen_quoted = false;
934                    for review_line in inline.lines() {
935                        if review_line.starts_with('>') {
936                            seen_quoted = true;
937                        }
938                        let style = classify_review_line(review_line, seen_quoted, palette);
939                        lines.push(Line::from(vec![
940                            Span::raw("    "),
941                            Span::styled(review_line, style),
942                        ]));
943                    }
944                    lines.push(Line::raw(""));
945                }
946            } else {
947                lines.push(Line::styled(
948                    "  (no review)",
949                    Style::default().fg(palette.muted.color()),
950                ));
951            }
952        }
953    }
954
955    lines.push(Line::raw(""));
956}
957
958/// Build thread message lines for the patchset detail view.
959///
960/// Each message in the patchset's associated thread is rendered as two
961/// lines: author + date, then subject. This is a flat list — messages
962/// are rendered in API-returned order without indentation.
963fn detail_thread_lines<'a>(
964    detail: &'a crate::models::PatchsetDetail,
965    palette: &'a ColorPalette,
966    lines: &mut Vec<Line<'a>>,
967) {
968    lines.push(Line::styled(
969        format!("── Thread ({}) ──", detail.thread.len()),
970        Style::default().fg(palette.accent.color()).bold(),
971    ));
972
973    if detail.thread.is_empty() {
974        lines.push(Line::styled(
975            "(no messages)",
976            Style::default().fg(palette.muted.color()),
977        ));
978    } else {
979        for msg in &detail.thread {
980            let author = msg.author.as_deref().unwrap_or("(unknown)");
981            let date = format_date(msg.date);
982            let subj = msg.subject.as_deref().unwrap_or("(no subject)");
983            lines.push(Line::from(vec![
984                Span::styled(author, Style::default().fg(palette.accent.color())),
985                Span::raw("  "),
986                Span::styled(date, Style::default().fg(palette.muted.color())),
987            ]));
988            lines.push(Line::styled(
989                format!("  {subj}"),
990                Style::default().fg(palette.foreground.color()),
991            ));
992        }
993    }
994}
995
996#[cfg(test)]
997#[allow(clippy::expect_used)]
998mod tests {
999    use super::*;
1000    use crate::config::Config;
1001    use crate::models::{FindingCounts, Paginated, Patchset};
1002    use ratatui::Terminal;
1003    use ratatui::backend::TestBackend;
1004
1005    fn make_config_with_remotes(names: &[&str]) -> Config {
1006        let mut config = Config::default();
1007        for name in names {
1008            config
1009                .remotes
1010                .push(crate::config::RemoteConfig::fixture(name));
1011        }
1012        config
1013    }
1014
1015    #[test]
1016    fn view_renders_without_panic() {
1017        let app = App::new(Config::default());
1018        let mut terminal = Terminal::new(TestBackend::new(80, 24)).expect("create test terminal");
1019        terminal
1020            .draw(|f| view(&app, f))
1021            .expect("draw should not fail");
1022        insta::assert_snapshot!(terminal.backend());
1023    }
1024
1025    #[test]
1026    fn view_renders_error_state() {
1027        let mut app = App::new(Config::default());
1028        app.error_state = Some("connection refused".to_string());
1029        let mut terminal = Terminal::new(TestBackend::new(80, 24)).expect("create test terminal");
1030        terminal
1031            .draw(|f| view(&app, f))
1032            .expect("draw with error should not fail");
1033        insta::assert_snapshot!(terminal.backend());
1034    }
1035
1036    #[test]
1037    fn view_renders_patchset_table() {
1038        let config = make_config_with_remotes(&["upstream"]);
1039        let mut app = App::new(config);
1040        app.patchsets = Paginated {
1041            items: vec![Patchset::fixture()],
1042            total: 1,
1043            page: 1,
1044            per_page: 50,
1045        };
1046        let mut terminal = Terminal::new(TestBackend::new(120, 24)).expect("create test terminal");
1047        terminal
1048            .draw(|f| view(&app, f))
1049            .expect("draw with patchsets should not fail");
1050        insta::assert_snapshot!(terminal.backend());
1051    }
1052
1053    #[test]
1054    fn view_renders_loading_state() {
1055        let config = make_config_with_remotes(&["upstream"]);
1056        let app = App::new(config);
1057        let mut terminal = Terminal::new(TestBackend::new(80, 24)).expect("create test terminal");
1058        terminal
1059            .draw(|f| view(&app, f))
1060            .expect("draw loading should not fail");
1061        insta::assert_snapshot!(terminal.backend());
1062    }
1063
1064    #[test]
1065    fn view_renders_sidebar_with_multiple_remotes() {
1066        let config = make_config_with_remotes(&["upstream", "staging", "local"]);
1067        let app = App::new(config);
1068        let mut terminal = Terminal::new(TestBackend::new(120, 24)).expect("create test terminal");
1069        terminal
1070            .draw(|f| view(&app, f))
1071            .expect("draw with sidebar should not fail");
1072        insta::assert_snapshot!(terminal.backend());
1073    }
1074
1075    #[test]
1076    fn view_renders_sidebar_focus() {
1077        let config = make_config_with_remotes(&["upstream", "staging"]);
1078        let mut app = App::new(config);
1079        app.focus = FocusPanel::Sidebar;
1080        let mut terminal = Terminal::new(TestBackend::new(120, 24)).expect("create test terminal");
1081        terminal
1082            .draw(|f| view(&app, f))
1083            .expect("draw with sidebar focus should not fail");
1084        insta::assert_snapshot!(terminal.backend());
1085    }
1086
1087    #[test]
1088    fn format_date_valid() {
1089        // 2026-05-13 (approx)
1090        let result = format_date(Some(1_778_690_980));
1091        assert_eq!(result, "2026-05-13");
1092    }
1093
1094    #[test]
1095    fn format_date_none() {
1096        assert_eq!(format_date(None), "—");
1097    }
1098
1099    #[test]
1100    fn format_date_epoch() {
1101        assert_eq!(format_date(Some(0)), "1970-01-01");
1102    }
1103
1104    #[test]
1105    fn format_findings_empty() {
1106        let palette = ColorPalette::default();
1107        let fc = FindingCounts::default();
1108        let line = format_findings(&fc, &palette);
1109        // Should show the dash placeholder
1110        assert_eq!(line.spans.len(), 1);
1111    }
1112
1113    #[test]
1114    fn format_findings_mixed() {
1115        let palette = ColorPalette::default();
1116        let fc = FindingCounts {
1117            low: 1,
1118            medium: 2,
1119            high: 1,
1120            critical: 0,
1121        };
1122        let line = format_findings(&fc, &palette);
1123        // Should have spans for H, M, L with separators
1124        let text: String = line.spans.iter().map(|s| s.content.to_string()).collect();
1125        assert!(text.contains("1H"));
1126        assert!(text.contains("2M"));
1127        assert!(text.contains("1L"));
1128    }
1129
1130    #[test]
1131    fn format_findings_critical_only() {
1132        let palette = ColorPalette::default();
1133        let fc = FindingCounts {
1134            low: 0,
1135            medium: 0,
1136            high: 0,
1137            critical: 3,
1138        };
1139        let line = format_findings(&fc, &palette);
1140        let text: String = line.spans.iter().map(|s| s.content.to_string()).collect();
1141        assert_eq!(text, "3C");
1142    }
1143
1144    #[test]
1145    fn status_color_maps_correctly() {
1146        let palette = ColorPalette::default();
1147        let reviewed = status_color(PatchsetStatus::Reviewed, &palette);
1148        assert_eq!(reviewed.fg, Some(palette.success.color()));
1149
1150        let failed = status_color(PatchsetStatus::Failed, &palette);
1151        assert_eq!(failed.fg, Some(palette.error.color()));
1152
1153        let in_review = status_color(PatchsetStatus::InReview, &palette);
1154        assert_eq!(in_review.fg, Some(palette.accent.color()));
1155    }
1156
1157    // --- diff-syntax-highlighting tests ---
1158
1159    // Raw diff lines (EmailMessage.diff path)
1160
1161    #[test]
1162    fn classify_diff_line_addition() {
1163        let palette = ColorPalette::default();
1164        let style = classify_diff_line("+    x = compute();", &palette);
1165        assert_eq!(style.fg, Some(palette.success.color()));
1166    }
1167
1168    #[test]
1169    fn classify_diff_line_deletion() {
1170        let palette = ColorPalette::default();
1171        let style = classify_diff_line("-    return 0;", &palette);
1172        assert_eq!(style.fg, Some(palette.error.color()));
1173    }
1174
1175    #[test]
1176    fn classify_diff_line_hunk_header() {
1177        let palette = ColorPalette::default();
1178        let style = classify_diff_line("@@ -10,3 +10,4 @@ int main(void)", &palette);
1179        assert_eq!(style.fg, Some(palette.accent.color()));
1180    }
1181
1182    #[test]
1183    fn classify_diff_line_file_header_plus() {
1184        let palette = ColorPalette::default();
1185        let style = classify_diff_line("+++ b/foo.c", &palette);
1186        assert_eq!(style.fg, Some(palette.foreground.color()));
1187    }
1188
1189    #[test]
1190    fn classify_diff_line_file_header_minus() {
1191        let palette = ColorPalette::default();
1192        let style = classify_diff_line("--- a/foo.c", &palette);
1193        assert_eq!(style.fg, Some(palette.foreground.color()));
1194    }
1195
1196    #[test]
1197    fn classify_diff_line_diff_header() {
1198        let palette = ColorPalette::default();
1199        let style = classify_diff_line("diff --git a/foo.c b/foo.c", &palette);
1200        assert_eq!(style.fg, Some(palette.foreground.color()));
1201    }
1202
1203    #[test]
1204    fn classify_diff_line_index_header() {
1205        let palette = ColorPalette::default();
1206        let style = classify_diff_line("index abc123..def456 100644", &palette);
1207        assert_eq!(style.fg, Some(palette.foreground.color()));
1208    }
1209
1210    #[test]
1211    fn classify_diff_line_context() {
1212        let palette = ColorPalette::default();
1213        let style = classify_diff_line(" int x = 0;", &palette);
1214        assert_eq!(style.fg, Some(palette.muted.color()));
1215    }
1216
1217    #[test]
1218    fn classify_diff_line_empty() {
1219        let palette = ColorPalette::default();
1220        let style = classify_diff_line("", &palette);
1221        assert_eq!(style.fg, Some(palette.muted.color()));
1222    }
1223
1224    // --- classify_review_line tests (inline_review path) ---
1225
1226    // Quoted diff lines — always colored by diff role regardless of seen_quoted
1227
1228    #[test]
1229    fn review_line_quoted_addition() {
1230        let palette = ColorPalette::default();
1231        let style = classify_review_line("> +    x = compute();", true, &palette);
1232        assert_eq!(style.fg, Some(palette.success.color()));
1233    }
1234
1235    #[test]
1236    fn review_line_quoted_deletion() {
1237        let palette = ColorPalette::default();
1238        let style = classify_review_line("> -    return 0;", true, &palette);
1239        assert_eq!(style.fg, Some(palette.error.color()));
1240    }
1241
1242    #[test]
1243    fn review_line_quoted_hunk_header() {
1244        let palette = ColorPalette::default();
1245        let style = classify_review_line("> @@ -10,3 +10,4 @@", true, &palette);
1246        assert_eq!(style.fg, Some(palette.accent.color()));
1247    }
1248
1249    #[test]
1250    fn review_line_quoted_file_header() {
1251        let palette = ColorPalette::default();
1252        let style = classify_review_line("> +++ b/foo.c", true, &palette);
1253        assert_eq!(style.fg, Some(palette.foreground.color()));
1254    }
1255
1256    #[test]
1257    fn review_line_double_quoted() {
1258        let palette = ColorPalette::default();
1259        let style = classify_review_line(">> +added in nested quote", true, &palette);
1260        assert_eq!(style.fg, Some(palette.success.color()));
1261    }
1262
1263    #[test]
1264    fn review_line_quoted_no_space() {
1265        let palette = ColorPalette::default();
1266        let style = classify_review_line(">+added line", true, &palette);
1267        assert_eq!(style.fg, Some(palette.success.color()));
1268    }
1269
1270    #[test]
1271    fn review_line_quoted_context() {
1272        let palette = ColorPalette::default();
1273        let style = classify_review_line(">  context line", true, &palette);
1274        assert_eq!(style.fg, Some(palette.muted.color()));
1275    }
1276
1277    // Patch metadata — unquoted text BEFORE any quoted diff (seen_quoted=false)
1278
1279    #[test]
1280    fn review_line_metadata_commit_hash() {
1281        let palette = ColorPalette::default();
1282        let style = classify_review_line(
1283            "commit 37076247c47b0c3300cbefe9a1791f066ad33f2e",
1284            false,
1285            &palette,
1286        );
1287        // Patch metadata is muted
1288        assert_eq!(style.fg, Some(palette.muted.color()));
1289    }
1290
1291    #[test]
1292    fn review_line_metadata_author() {
1293        let palette = ColorPalette::default();
1294        let style =
1295            classify_review_line("Author: Audra Mitchell <audra@redhat.com>", false, &palette);
1296        assert_eq!(style.fg, Some(palette.muted.color()));
1297    }
1298
1299    #[test]
1300    fn review_line_metadata_subject() {
1301        let palette = ColorPalette::default();
1302        let style = classify_review_line(
1303            "Subject: mm/hugetlb: fix avoid_reserve to allow taking folio from subpool",
1304            false,
1305            &palette,
1306        );
1307        assert_eq!(style.fg, Some(palette.muted.color()));
1308    }
1309
1310    #[test]
1311    fn review_line_metadata_link() {
1312        let palette = ColorPalette::default();
1313        let style = classify_review_line(
1314            "Link: https://lkml.kernel.org/r/20250107204002.2683356-1-peterx@redhat.com",
1315            false,
1316            &palette,
1317        );
1318        assert_eq!(style.fg, Some(palette.muted.color()));
1319    }
1320
1321    #[test]
1322    fn review_line_metadata_description() {
1323        let palette = ColorPalette::default();
1324        let style = classify_review_line(
1325            "This commit backports an upstream change to allow hugetlb COW faults",
1326            false,
1327            &palette,
1328        );
1329        assert_eq!(style.fg, Some(palette.muted.color()));
1330    }
1331
1332    // Reviewer commentary — unquoted text AFTER quoted diff (seen_quoted=true)
1333
1334    #[test]
1335    fn review_line_commentary_is_foreground() {
1336        let palette = ColorPalette::default();
1337        let style = classify_review_line("LGTM. The null check looks correct.", true, &palette);
1338        assert_eq!(style.fg, Some(palette.foreground.color()));
1339    }
1340
1341    #[test]
1342    fn review_line_commentary_suggestion() {
1343        let palette = ColorPalette::default();
1344        let style = classify_review_line("Consider adding a dev_err() log here.", true, &palette);
1345        assert_eq!(style.fg, Some(palette.foreground.color()));
1346    }
1347
1348    #[test]
1349    fn review_line_commentary_question() {
1350        let palette = ColorPalette::default();
1351        let style = classify_review_line("Should this be guarded by a mutex?", true, &palette);
1352        assert_eq!(style.fg, Some(palette.foreground.color()));
1353    }
1354
1355    // Empty lines — always unstyled regardless of phase
1356
1357    #[test]
1358    fn review_line_empty_is_unstyled() {
1359        let palette = ColorPalette::default();
1360        let style = classify_review_line("", false, &palette);
1361        assert_eq!(style.fg, None);
1362    }
1363
1364    #[test]
1365    fn review_line_empty_after_quoted_is_unstyled() {
1366        let palette = ColorPalette::default();
1367        let style = classify_review_line("", true, &palette);
1368        assert_eq!(style.fg, None);
1369    }
1370
1371    // Full inline review simulation — test the stateful rendering loop
1372
1373    #[test]
1374    fn review_stateful_classification_full_review() {
1375        let palette = ColorPalette::default();
1376        let review = concat!(
1377            "commit abc123\n",
1378            "Author: dev@example.com\n",
1379            "Subject: Fix null deref\n",
1380            "\n",
1381            "This fixes the bug.\n",
1382            "\n",
1383            "> diff --git a/foo.c b/foo.c\n",
1384            "> --- a/foo.c\n",
1385            "> +++ b/foo.c\n",
1386            "> @@ -10,3 +10,4 @@\n",
1387            ">  int x = 0;\n",
1388            "> -    return 0;\n",
1389            "> +    x = compute();\n",
1390            "\n",
1391            "Good fix. Consider also checking for overflow.\n",
1392        );
1393
1394        let mut seen_quoted = false;
1395        let mut styles: Vec<Option<ratatui::style::Color>> = Vec::new();
1396        for line in review.lines() {
1397            if line.starts_with('>') {
1398                seen_quoted = true;
1399            }
1400            let style = classify_review_line(line, seen_quoted, &palette);
1401            styles.push(style.fg);
1402        }
1403
1404        // Lines 0-2: commit, Author, Subject — metadata (muted)
1405        assert_eq!(styles[0], Some(palette.muted.color()), "commit hash");
1406        assert_eq!(styles[1], Some(palette.muted.color()), "Author");
1407        assert_eq!(styles[2], Some(palette.muted.color()), "Subject");
1408        // Line 3: empty
1409        assert_eq!(styles[3], None, "empty separator");
1410        // Line 4: description prose — metadata (muted, before any >)
1411        assert_eq!(styles[4], Some(palette.muted.color()), "description");
1412        // Line 5: empty
1413        assert_eq!(styles[5], None, "empty separator");
1414        // Lines 6-12: quoted diff content
1415        assert_eq!(styles[6], Some(palette.foreground.color()), "diff header"); // bold
1416        assert_eq!(styles[7], Some(palette.foreground.color()), "--- header"); // bold
1417        assert_eq!(styles[8], Some(palette.foreground.color()), "+++ header"); // bold
1418        assert_eq!(styles[9], Some(palette.accent.color()), "@@ hunk");
1419        assert_eq!(styles[10], Some(palette.muted.color()), "context");
1420        assert_eq!(styles[11], Some(palette.error.color()), "deletion");
1421        assert_eq!(styles[12], Some(palette.success.color()), "addition");
1422        // Line 13: empty after quoted section
1423        assert_eq!(styles[13], None, "empty separator");
1424        // Line 14: reviewer commentary (foreground, after quoted)
1425        assert_eq!(styles[14], Some(palette.foreground.color()), "commentary");
1426    }
1427
1428    // strip_review_quoting unit tests
1429
1430    #[test]
1431    fn strip_review_quoting_no_prefix() {
1432        assert_eq!(strip_review_quoting("+added line"), "+added line");
1433    }
1434
1435    #[test]
1436    fn strip_review_quoting_single_level() {
1437        assert_eq!(strip_review_quoting("> +added line"), "+added line");
1438    }
1439
1440    #[test]
1441    fn strip_review_quoting_double_level() {
1442        assert_eq!(strip_review_quoting(">> +added line"), "+added line");
1443    }
1444
1445    #[test]
1446    fn strip_review_quoting_no_space() {
1447        assert_eq!(strip_review_quoting(">+added line"), "+added line");
1448    }
1449
1450    #[test]
1451    fn strip_review_quoting_empty() {
1452        assert_eq!(strip_review_quoting(""), "");
1453    }
1454
1455    #[test]
1456    fn strip_review_quoting_only_chevron() {
1457        assert_eq!(strip_review_quoting("> "), "");
1458    }
1459
1460    // --- thread rendering test ---
1461
1462    #[test]
1463    fn detail_thread_lines_renders_flat() {
1464        use crate::models::{PatchsetDetail, ThreadMessage};
1465
1466        let mut detail = PatchsetDetail::fixture();
1467        detail.thread = vec![
1468            ThreadMessage {
1469                id: 1,
1470                message_id: Some("msg-1@example.com".to_string()),
1471                author: Some("developer@kernel.org".to_string()),
1472                date: Some(1_778_690_980),
1473                subject: Some("[PATCH v2 0/3] Fix null deref".to_string()),
1474                in_reply_to: None,
1475            },
1476            ThreadMessage {
1477                id: 2,
1478                message_id: Some("msg-2@example.com".to_string()),
1479                author: Some("maintainer@kernel.org".to_string()),
1480                date: Some(1_778_700_000),
1481                subject: Some("Re: [PATCH v2 0/3] Fix null deref".to_string()),
1482                in_reply_to: Some("msg-1@example.com".to_string()),
1483            },
1484        ];
1485
1486        let palette = ColorPalette::default();
1487        let mut lines: Vec<Line> = Vec::new();
1488        detail_thread_lines(&detail, &palette, &mut lines);
1489
1490        // Line 0: section header
1491        assert!(lines[0].to_string().contains("Thread (2)"));
1492
1493        // Lines 1-2: first message (flat, no indent)
1494        assert!(lines[1].spans[0].content.contains("developer@kernel.org"));
1495
1496        // Lines 3-4: second message (flat, no indent)
1497        assert!(lines[3].spans[0].content.contains("maintainer@kernel.org"));
1498
1499        // 1 header + 2 messages × 2 lines = 5 lines
1500        assert_eq!(lines.len(), 5);
1501    }
1502
1503    #[test]
1504    fn detail_header_lines_basic() {
1505        let palette = ColorPalette::default();
1506        let detail = crate::models::PatchsetDetail::fixture();
1507        let mut lines: Vec<Line> = Vec::new();
1508        detail_header_lines(&detail, &palette, &mut lines);
1509        // Should have at least status line + empty separator
1510        assert!(!lines.is_empty());
1511        // Last line should be the empty separator
1512        let last = lines.last().expect("should have lines");
1513        assert!(last.spans.is_empty() || last.to_string().is_empty());
1514    }
1515
1516    #[test]
1517    fn detail_header_lines_with_failure_reason() {
1518        let palette = ColorPalette::default();
1519        let mut detail = crate::models::PatchsetDetail::fixture();
1520        detail.failed_reason = Some("compile error".to_string());
1521        let mut lines: Vec<Line> = Vec::new();
1522        detail_header_lines(&detail, &palette, &mut lines);
1523        let text: String = lines
1524            .iter()
1525            .map(std::string::ToString::to_string)
1526            .collect::<Vec<_>>()
1527            .join("\n");
1528        assert!(text.contains("compile error"), "got: {text}");
1529    }
1530
1531    #[test]
1532    fn detail_header_lines_with_baseline() {
1533        let palette = ColorPalette::default();
1534        let mut detail = crate::models::PatchsetDetail::fixture();
1535        detail.baseline = Some(crate::models::Baseline {
1536            branch: Some("main".to_string()),
1537            commit: Some("abc123def456".to_string()),
1538            repo_url: None,
1539        });
1540        let mut lines: Vec<Line> = Vec::new();
1541        detail_header_lines(&detail, &palette, &mut lines);
1542        let text: String = lines
1543            .iter()
1544            .map(std::string::ToString::to_string)
1545            .collect::<Vec<_>>()
1546            .join("\n");
1547        assert!(text.contains("Baseline:"), "got: {text}");
1548        assert!(text.contains("main"), "got: {text}");
1549    }
1550
1551    #[test]
1552    fn detail_header_lines_with_model() {
1553        let palette = ColorPalette::default();
1554        let mut detail = crate::models::PatchsetDetail::fixture();
1555        detail.model_name = Some("gpt-4".to_string());
1556        detail.provider = Some("openai".to_string());
1557        let mut lines: Vec<Line> = Vec::new();
1558        detail_header_lines(&detail, &palette, &mut lines);
1559        let text: String = lines
1560            .iter()
1561            .map(std::string::ToString::to_string)
1562            .collect::<Vec<_>>()
1563            .join("\n");
1564        assert!(text.contains("Model: openai/gpt-4"), "got: {text}");
1565    }
1566
1567    #[test]
1568    fn detail_patches_lines_empty() {
1569        let palette = ColorPalette::default();
1570        let mut detail = crate::models::PatchsetDetail::fixture();
1571        detail.patches.clear();
1572        let mut lines: Vec<Line> = Vec::new();
1573        detail_patches_lines(&detail, &palette, &mut lines);
1574        let text: String = lines
1575            .iter()
1576            .map(std::string::ToString::to_string)
1577            .collect::<Vec<_>>()
1578            .join("\n");
1579        assert!(text.contains("Patches (0)"), "got: {text}");
1580        assert!(text.contains("(no patches)"), "got: {text}");
1581    }
1582
1583    #[test]
1584    fn detail_patches_lines_with_patches() {
1585        let palette = ColorPalette::default();
1586        let detail = crate::models::PatchsetDetail::fixture();
1587        let mut lines: Vec<Line> = Vec::new();
1588        detail_patches_lines(&detail, &palette, &mut lines);
1589        // Should have header + at least one patch line
1590        assert!(lines.len() >= 2);
1591        let text: String = lines
1592            .iter()
1593            .map(std::string::ToString::to_string)
1594            .collect::<Vec<_>>()
1595            .join("\n");
1596        assert!(text.contains("Patches ("), "got: {text}");
1597    }
1598
1599    #[test]
1600    fn message_detail_lines_basic() {
1601        let palette = ColorPalette::default();
1602        let msg = crate::models::EmailMessage::fixture();
1603        let lines = message_detail_lines(&msg, &palette);
1604        let text: String = lines
1605            .iter()
1606            .map(std::string::ToString::to_string)
1607            .collect::<Vec<_>>()
1608            .join("\n");
1609        assert!(text.contains("From:"), "got: {text}");
1610        assert!(text.contains("Date:"), "got: {text}");
1611    }
1612
1613    #[test]
1614    fn message_detail_lines_with_optional_fields() {
1615        let palette = ColorPalette::default();
1616        let mut msg = crate::models::EmailMessage::fixture();
1617        msg.to = Some("recipient@example.com".to_string());
1618        msg.cc = Some("cc@example.com".to_string());
1619        msg.mailing_list = Some("linux-kernel".to_string());
1620        let lines = message_detail_lines(&msg, &palette);
1621        let text: String = lines
1622            .iter()
1623            .map(std::string::ToString::to_string)
1624            .collect::<Vec<_>>()
1625            .join("\n");
1626        assert!(text.contains("To:"), "got: {text}");
1627        assert!(text.contains("Cc:"), "got: {text}");
1628        assert!(text.contains("List:"), "got: {text}");
1629    }
1630
1631    #[test]
1632    fn message_detail_lines_with_body() {
1633        let palette = ColorPalette::default();
1634        let mut msg = crate::models::EmailMessage::fixture();
1635        msg.body = Some("Hello world\nSecond line".to_string());
1636        let lines = message_detail_lines(&msg, &palette);
1637        let text: String = lines
1638            .iter()
1639            .map(std::string::ToString::to_string)
1640            .collect::<Vec<_>>()
1641            .join("\n");
1642        assert!(text.contains("Hello world"), "got: {text}");
1643        assert!(text.contains("Second line"), "got: {text}");
1644    }
1645
1646    #[test]
1647    fn message_detail_lines_no_body() {
1648        let palette = ColorPalette::default();
1649        let mut msg = crate::models::EmailMessage::fixture();
1650        msg.body = None;
1651        let lines = message_detail_lines(&msg, &palette);
1652        let text: String = lines
1653            .iter()
1654            .map(std::string::ToString::to_string)
1655            .collect::<Vec<_>>()
1656            .join("\n");
1657        assert!(text.contains("(no body)"), "got: {text}");
1658    }
1659
1660    #[test]
1661    fn message_detail_lines_with_diff() {
1662        let palette = ColorPalette::default();
1663        let msg = crate::models::EmailMessage::fixture_with_diff();
1664        let lines = message_detail_lines(&msg, &palette);
1665        let text: String = lines
1666            .iter()
1667            .map(std::string::ToString::to_string)
1668            .collect::<Vec<_>>()
1669            .join("\n");
1670        assert!(text.contains("Diff"), "got: {text}");
1671    }
1672}