Skip to main content

semantic_diff/ui/
review_view.rs

1use crate::app::App;
2use crate::preview::markdown::{parse_markdown, PreviewBlock};
3use crate::review::{ReviewSection, ReviewSource, SectionState};
4use ratatui::layout::Rect;
5use ratatui::style::{Color, Modifier, Style};
6use ratatui::text::{Line, Span};
7use ratatui::widgets::{Paragraph, Wrap};
8use ratatui::Frame;
9
10/// Render the review pane: banner + review sections + HR + diff below.
11pub fn render_review_with_diff(app: &App, frame: &mut Frame, area: Rect) {
12    let mut lines: Vec<Line> = Vec::new();
13
14    // 1. Review source banner
15    render_banner(&app.review_source, &mut lines, &app.theme);
16
17    // 2. Title bar with progress
18    if let Some(hash) = app.active_review_group {
19        if let Some(review) = app.review_cache.get(&hash) {
20            // Find group label
21            let label = find_group_label(app, hash);
22            let completed = review
23                .sections
24                .values()
25                .filter(|s| s.is_complete())
26                .count();
27            lines.push(Line::from(vec![
28                Span::styled(
29                    format!(" Review: \"{}\"", label),
30                    Style::default()
31                        .fg(app.theme.help_section_fg)
32                        .add_modifier(Modifier::BOLD),
33                ),
34                Span::styled(
35                    format!("  [{}/4]", completed),
36                    Style::default().fg(app.theme.help_dismiss_fg),
37                ),
38            ]));
39            lines.push(Line::raw(""));
40
41            // 3. Render each section in order
42            for section in ReviewSection::all() {
43                if let Some(state) = review.sections.get(&section) {
44                    render_section(section, state, &mut lines, &app.theme, area.width);
45                }
46            }
47        }
48    }
49
50    // 4. Horizontal rule separator
51    let hr = "─".repeat(area.width as usize);
52    lines.push(Line::raw(""));
53    lines.push(Line::from(Span::styled(
54        hr,
55        Style::default().fg(app.theme.help_dismiss_fg),
56    )));
57    lines.push(Line::raw(""));
58
59    // 5. Render the normal diff below
60    let diff_lines = build_diff_lines(app);
61    lines.extend(diff_lines);
62
63    // Apply scroll
64    let scroll = app.review_scroll as u16;
65    let paragraph = Paragraph::new(lines)
66        .scroll((scroll, 0))
67        .wrap(Wrap { trim: false });
68    frame.render_widget(paragraph, area);
69}
70
71fn render_banner(source: &ReviewSource, lines: &mut Vec<Line>, theme: &crate::theme::Theme) {
72    match source {
73        ReviewSource::Skill { name, path } => {
74            lines.push(Line::from(vec![
75                Span::styled(
76                    " Reviewed with: ",
77                    Style::default().fg(theme.help_dismiss_fg),
78                ),
79                Span::styled(
80                    format!("\"{}\"", name),
81                    Style::default()
82                        .fg(theme.help_section_fg)
83                        .add_modifier(Modifier::BOLD),
84                ),
85            ]));
86            lines.push(Line::from(Span::styled(
87                format!(" {}", path.display()),
88                Style::default().fg(theme.help_dismiss_fg),
89            )));
90        }
91        ReviewSource::BuiltIn => {
92            lines.push(Line::from(Span::styled(
93                " Reviewed with: built-in generic reviewer",
94                Style::default().fg(theme.help_dismiss_fg),
95            )));
96            lines.push(Line::from(Span::styled(
97                " Tip: Create a review SKILL in .claude/skills/ or ~/.claude/skills/",
98                Style::default().fg(theme.help_dismiss_fg),
99            )));
100        }
101    }
102    lines.push(Line::raw(""));
103}
104
105fn render_section(
106    section: ReviewSection,
107    state: &SectionState,
108    lines: &mut Vec<Line>,
109    theme: &crate::theme::Theme,
110    width: u16,
111) {
112    match state {
113        SectionState::Loading => {
114            lines.push(Line::from(Span::styled(
115                format!("  {} Loading {}...", "⠋", section.label()),
116                Style::default().fg(Color::Yellow),
117            )));
118            lines.push(Line::raw(""));
119        }
120        SectionState::Ready(content) => {
121            // Section header
122            lines.push(Line::from(Span::styled(
123                format!(" {} ", section.label()),
124                Style::default()
125                    .fg(theme.help_section_fg)
126                    .add_modifier(Modifier::BOLD),
127            )));
128
129            // Parse and render as markdown
130            let blocks = parse_markdown(content, width.saturating_sub(2), theme);
131            for block in blocks {
132                match block {
133                    PreviewBlock::Text(md_lines) => {
134                        for line in md_lines {
135                            // Indent each line by 1 space
136                            let mut spans = vec![Span::raw(" ")];
137                            spans.extend(line.spans);
138                            lines.push(Line::from(spans));
139                        }
140                    }
141                    PreviewBlock::Mermaid(mermaid_block) => {
142                        // Render mermaid as styled source in review pane
143                        lines.push(Line::from(Span::styled(
144                            " ```mermaid".to_string(),
145                            Style::default().fg(theme.help_dismiss_fg),
146                        )));
147                        for src_line in mermaid_block.source.lines() {
148                            lines.push(Line::from(Span::styled(
149                                format!(" {src_line}"),
150                                Style::default().fg(theme.md_code_block_fg),
151                            )));
152                        }
153                        lines.push(Line::from(Span::styled(
154                            " ```".to_string(),
155                            Style::default().fg(theme.help_dismiss_fg),
156                        )));
157                    }
158                }
159            }
160            lines.push(Line::raw(""));
161        }
162        SectionState::Error(msg) => {
163            lines.push(Line::from(Span::styled(
164                format!("  [{} failed: {}]", section.label(), msg),
165                Style::default()
166                    .fg(Color::Red)
167                    .add_modifier(Modifier::DIM),
168            )));
169            lines.push(Line::raw(""));
170        }
171        SectionState::Skipped => {
172            // Not rendered
173        }
174    }
175}
176
177/// Find the group label for a given content hash.
178fn find_group_label(app: &App, hash: u64) -> String {
179    if let Some(groups) = &app.semantic_groups {
180        for group in groups {
181            if crate::review::group_content_hash(group) == hash {
182                return group.label.clone();
183            }
184        }
185    }
186    "Unknown".to_string()
187}
188
189/// Build diff lines from the current visible items, reusing diff_view's rendering.
190fn build_diff_lines(app: &App) -> Vec<Line<'static>> {
191    let items = app.visible_items();
192    let mut lines = Vec::new();
193    for (idx, item) in items.iter().enumerate() {
194        let is_selected = idx == app.ui_state.selected_index;
195        lines.push(crate::ui::diff_view::render_item(app, item, is_selected));
196    }
197    lines
198}