Skip to main content

semantic_diff/ui/
diff_view.rs

1use crate::app::{App, NodeId, VisibleItem};
2use crate::diff::{LineType, SegmentTag};
3use ratatui::layout::Rect;
4use ratatui::style::{Color, Modifier, Style};
5use ratatui::text::{Line, Span};
6use ratatui::Frame;
7
8/// Render the diff view in the given area.
9pub fn render_diff(app: &App, frame: &mut Frame, area: Rect) {
10    let items = app.visible_items();
11    let scroll = app.ui_state.scroll_offset as usize;
12    let viewport_height = area.height as usize;
13
14    // Store width so adjust_scroll can account for wrapping
15    app.ui_state.diff_view_width.set(area.width);
16
17    let mut lines: Vec<Line> = Vec::new();
18    let mut visual_rows_used = 0usize;
19
20    for (idx, item) in items.iter().enumerate().skip(scroll) {
21        if visual_rows_used >= viewport_height {
22            break;
23        }
24        let is_selected = idx == app.ui_state.selected_index;
25        let line = render_item(app, item, is_selected);
26        let char_width: usize = line.spans.iter().map(|s| s.content.len()).sum();
27        let wrapped_rows = if area.width > 0 && char_width > 0 {
28            char_width.div_ceil(area.width as usize)
29        } else {
30            1
31        };
32        visual_rows_used += wrapped_rows;
33        lines.push(line);
34    }
35
36    let paragraph = ratatui::widgets::Paragraph::new(lines)
37        .wrap(ratatui::widgets::Wrap { trim: false });
38    frame.render_widget(paragraph, area);
39}
40
41/// Render a single visible item as a Line.
42fn render_item(app: &App, item: &VisibleItem, is_selected: bool) -> Line<'static> {
43    let sel_bg = if is_selected {
44        app.theme.selection_bg
45    } else {
46        Color::Reset
47    };
48
49    match item {
50        VisibleItem::FileHeader { file_idx } => render_file_header(app, *file_idx, sel_bg),
51        VisibleItem::HunkHeader { file_idx, hunk_idx } => {
52            render_hunk_header(app, *file_idx, *hunk_idx, sel_bg)
53        }
54        VisibleItem::DiffLine {
55            file_idx,
56            hunk_idx,
57            line_idx,
58        } => render_diff_line(app, *file_idx, *hunk_idx, *line_idx, is_selected),
59    }
60}
61
62/// Render a file header line with collapse indicator, name, and +/- stats.
63/// When an active filter is set, highlights the matching portion of the filename.
64fn render_file_header(app: &App, file_idx: usize, sel_bg: Color) -> Line<'static> {
65    let file = &app.diff_data.files[file_idx];
66    let is_collapsed = app
67        .ui_state
68        .collapsed
69        .contains(&NodeId::File(file_idx));
70    let indicator = if is_collapsed { ">" } else { "v" };
71
72    let name = if file.is_rename {
73        format!(
74            "renamed: {} -> {}",
75            file.source_file.trim_start_matches("a/"),
76            file.target_file.trim_start_matches("b/")
77        )
78    } else {
79        file.target_file.trim_start_matches("b/").to_string()
80    };
81
82    let header_bg = if sel_bg != Color::Reset {
83        sel_bg
84    } else {
85        app.theme.file_header_bg
86    };
87
88    let mut spans = vec![
89        Span::styled(
90            format!(" {indicator} "),
91            Style::default().fg(Color::Yellow).bg(header_bg),
92        ),
93    ];
94
95    // Render filename with match highlighting if filter is active
96    let name_with_space = format!("{name} ");
97    if let Some(ref filter) = app.active_filter {
98        let name_lower = name_with_space.to_lowercase();
99        let filter_lower = filter.to_lowercase();
100        if let Some(pos) = name_lower.find(&filter_lower) {
101            let before = &name_with_space[..pos];
102            let matched = &name_with_space[pos..pos + filter.len()];
103            let after = &name_with_space[pos + filter.len()..];
104
105            if !before.is_empty() {
106                spans.push(Span::styled(
107                    before.to_string(),
108                    Style::default()
109                        .fg(app.theme.file_header_fg)
110                        .bg(header_bg)
111                        .add_modifier(Modifier::BOLD),
112                ));
113            }
114            spans.push(Span::styled(
115                matched.to_string(),
116                Style::default()
117                    .fg(app.theme.search_match_fg)
118                    .bg(app.theme.search_match_bg)
119                    .add_modifier(Modifier::BOLD),
120            ));
121            if !after.is_empty() {
122                spans.push(Span::styled(
123                    after.to_string(),
124                    Style::default()
125                        .fg(app.theme.file_header_fg)
126                        .bg(header_bg)
127                        .add_modifier(Modifier::BOLD),
128                ));
129            }
130        } else {
131            spans.push(Span::styled(
132                name_with_space,
133                Style::default()
134                    .fg(app.theme.file_header_fg)
135                    .bg(header_bg)
136                    .add_modifier(Modifier::BOLD),
137            ));
138        }
139    } else {
140        spans.push(Span::styled(
141            name_with_space,
142            Style::default()
143                .fg(app.theme.file_header_fg)
144                .bg(header_bg)
145                .add_modifier(Modifier::BOLD),
146        ));
147    }
148
149    spans.push(Span::styled(
150        format!("+{}", file.added_count),
151        Style::default().fg(Color::Green).bg(header_bg),
152    ));
153    spans.push(Span::styled(
154        format!(" -{}", file.removed_count),
155        Style::default().fg(Color::Red).bg(header_bg),
156    ));
157
158    Line::from(spans)
159}
160
161/// Render a hunk header line with collapse indicator and @@ header.
162fn render_hunk_header(
163    app: &App,
164    file_idx: usize,
165    hunk_idx: usize,
166    sel_bg: Color,
167) -> Line<'static> {
168    let hunk = &app.diff_data.files[file_idx].hunks[hunk_idx];
169    let is_collapsed = app
170        .ui_state
171        .collapsed
172        .contains(&NodeId::Hunk(file_idx, hunk_idx));
173    let indicator = if is_collapsed { ">" } else { "v" };
174
175    Line::from(vec![
176        Span::styled(
177            format!("   {indicator} "),
178            Style::default().fg(Color::Yellow).bg(sel_bg),
179        ),
180        Span::styled(
181            hunk.header.clone(),
182            Style::default()
183                .fg(Color::Cyan)
184                .bg(sel_bg)
185                .add_modifier(Modifier::DIM),
186        ),
187    ])
188}
189
190/// Render a diff line with line number gutter, +/- prefix, and syntax highlighting.
191fn render_diff_line(
192    app: &App,
193    file_idx: usize,
194    hunk_idx: usize,
195    line_idx: usize,
196    is_selected: bool,
197) -> Line<'static> {
198    let hunk = &app.diff_data.files[file_idx].hunks[hunk_idx];
199    let line = &hunk.lines[line_idx];
200
201    let (prefix, fg, bg) = match line.line_type {
202        LineType::Added => ("+", Color::Green, app.theme.added_line_bg),
203        LineType::Removed => ("-", Color::Red, app.theme.removed_line_bg),
204        LineType::Context => (" ", app.theme.context_fg, app.theme.context_bg),
205    };
206
207    let final_bg = if is_selected {
208        app.theme.selection_bg
209    } else {
210        bg
211    };
212
213    // Compute line numbers
214    let (src_num, tgt_num) = compute_line_numbers(hunk, line_idx);
215    let gutter = format_gutter(src_num, tgt_num);
216
217    let mut spans = vec![
218        // Line number gutter
219        Span::styled(
220            gutter,
221            Style::default().fg(app.theme.gutter_fg).bg(final_bg),
222        ),
223        // +/- prefix
224        Span::styled(
225            format!("{prefix} "),
226            Style::default().fg(fg).bg(final_bg),
227        ),
228    ];
229
230    // Render content: with inline diff segments if available, otherwise syntax highlighting
231    if let Some(segments) = &line.inline_segments {
232        // Inline diff mode: render segments with emphasis on changed parts
233        let emphasis_bg = match line.line_type {
234            LineType::Added => app.theme.added_emphasis_bg,
235            LineType::Removed => app.theme.removed_emphasis_bg,
236            LineType::Context => final_bg,
237        };
238
239        for segment in segments {
240            let seg_bg = if is_selected {
241                app.theme.selection_bg
242            } else {
243                match segment.tag {
244                    SegmentTag::Changed => emphasis_bg,
245                    SegmentTag::Equal => bg,
246                }
247            };
248            let seg_modifier = if segment.tag == SegmentTag::Changed {
249                Modifier::BOLD
250            } else {
251                Modifier::empty()
252            };
253            spans.push(Span::styled(
254                segment.text.clone(),
255                Style::default().fg(fg).bg(seg_bg).add_modifier(seg_modifier),
256            ));
257        }
258    } else if let Some(highlighted) = app.highlight_cache.get(file_idx, hunk_idx, line_idx) {
259        // Syntax highlighting mode
260        for (style, text) in highlighted {
261            spans.push(Span::styled(text.clone(), style.bg(final_bg)));
262        }
263    } else {
264        // Fallback: plain text
265        spans.push(Span::styled(
266            line.content.clone(),
267            Style::default().fg(fg).bg(final_bg),
268        ));
269    }
270
271    Line::from(spans)
272}
273
274/// Compute source and target line numbers for a given line within a hunk.
275fn compute_line_numbers(
276    hunk: &crate::diff::Hunk,
277    target_line_idx: usize,
278) -> (Option<usize>, Option<usize>) {
279    let mut src_line = hunk.source_start;
280    let mut tgt_line = hunk.target_start;
281
282    for (i, line) in hunk.lines.iter().enumerate() {
283        if i == target_line_idx {
284            return match line.line_type {
285                LineType::Added => (None, Some(tgt_line)),
286                LineType::Removed => (Some(src_line), None),
287                LineType::Context => (Some(src_line), Some(tgt_line)),
288            };
289        }
290        match line.line_type {
291            LineType::Added => tgt_line += 1,
292            LineType::Removed => src_line += 1,
293            LineType::Context => {
294                src_line += 1;
295                tgt_line += 1;
296            }
297        }
298    }
299    (None, None)
300}
301
302/// Format the line number gutter (4 chars for source, 4 chars for target).
303fn format_gutter(src: Option<usize>, tgt: Option<usize>) -> String {
304    let s = src
305        .map(|n| format!("{n:>4}"))
306        .unwrap_or_else(|| "    ".to_string());
307    let t = tgt
308        .map(|n| format!("{n:>4}"))
309        .unwrap_or_else(|| "    ".to_string());
310    format!("{s} {t} ")
311}