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    // Show [untracked] badge for untracked files
96    if file.is_untracked {
97        spans.push(Span::styled(
98            "[untracked] ".to_string(),
99            Style::default()
100                .fg(Color::Cyan)
101                .bg(header_bg)
102                .add_modifier(Modifier::DIM),
103        ));
104    }
105
106    // Render filename with match highlighting if filter is active
107    let name_with_space = format!("{name} ");
108    if let Some(ref filter) = app.active_filter {
109        let name_lower = name_with_space.to_lowercase();
110        let filter_lower = filter.to_lowercase();
111        if let Some(pos) = name_lower.find(&filter_lower) {
112            let before = &name_with_space[..pos];
113            let matched = &name_with_space[pos..pos + filter.len()];
114            let after = &name_with_space[pos + filter.len()..];
115
116            if !before.is_empty() {
117                spans.push(Span::styled(
118                    before.to_string(),
119                    Style::default()
120                        .fg(app.theme.file_header_fg)
121                        .bg(header_bg)
122                        .add_modifier(Modifier::BOLD),
123                ));
124            }
125            spans.push(Span::styled(
126                matched.to_string(),
127                Style::default()
128                    .fg(app.theme.search_match_fg)
129                    .bg(app.theme.search_match_bg)
130                    .add_modifier(Modifier::BOLD),
131            ));
132            if !after.is_empty() {
133                spans.push(Span::styled(
134                    after.to_string(),
135                    Style::default()
136                        .fg(app.theme.file_header_fg)
137                        .bg(header_bg)
138                        .add_modifier(Modifier::BOLD),
139                ));
140            }
141        } else {
142            spans.push(Span::styled(
143                name_with_space,
144                Style::default()
145                    .fg(app.theme.file_header_fg)
146                    .bg(header_bg)
147                    .add_modifier(Modifier::BOLD),
148            ));
149        }
150    } else {
151        spans.push(Span::styled(
152            name_with_space,
153            Style::default()
154                .fg(app.theme.file_header_fg)
155                .bg(header_bg)
156                .add_modifier(Modifier::BOLD),
157        ));
158    }
159
160    spans.push(Span::styled(
161        format!("+{}", file.added_count),
162        Style::default().fg(Color::Green).bg(header_bg),
163    ));
164    spans.push(Span::styled(
165        format!(" -{}", file.removed_count),
166        Style::default().fg(Color::Red).bg(header_bg),
167    ));
168
169    Line::from(spans)
170}
171
172/// Render a hunk header line with collapse indicator and @@ header.
173fn render_hunk_header(
174    app: &App,
175    file_idx: usize,
176    hunk_idx: usize,
177    sel_bg: Color,
178) -> Line<'static> {
179    let hunk = &app.diff_data.files[file_idx].hunks[hunk_idx];
180    let is_collapsed = app
181        .ui_state
182        .collapsed
183        .contains(&NodeId::Hunk(file_idx, hunk_idx));
184    let indicator = if is_collapsed { ">" } else { "v" };
185
186    Line::from(vec![
187        Span::styled(
188            format!("   {indicator} "),
189            Style::default().fg(Color::Yellow).bg(sel_bg),
190        ),
191        Span::styled(
192            hunk.header.clone(),
193            Style::default()
194                .fg(Color::Cyan)
195                .bg(sel_bg)
196                .add_modifier(Modifier::DIM),
197        ),
198    ])
199}
200
201/// Render a diff line with line number gutter, +/- prefix, and syntax highlighting.
202fn render_diff_line(
203    app: &App,
204    file_idx: usize,
205    hunk_idx: usize,
206    line_idx: usize,
207    is_selected: bool,
208) -> Line<'static> {
209    let hunk = &app.diff_data.files[file_idx].hunks[hunk_idx];
210    let line = &hunk.lines[line_idx];
211
212    let (prefix, fg, bg) = match line.line_type {
213        LineType::Added => ("+", Color::Green, app.theme.added_line_bg),
214        LineType::Removed => ("-", Color::Red, app.theme.removed_line_bg),
215        LineType::Context => (" ", app.theme.context_fg, app.theme.context_bg),
216    };
217
218    let final_bg = if is_selected {
219        app.theme.selection_bg
220    } else {
221        bg
222    };
223
224    // Compute line numbers
225    let (src_num, tgt_num) = compute_line_numbers(hunk, line_idx);
226    let gutter = format_gutter(src_num, tgt_num);
227
228    let mut spans = vec![
229        // Line number gutter
230        Span::styled(
231            gutter,
232            Style::default().fg(app.theme.gutter_fg).bg(final_bg),
233        ),
234        // +/- prefix
235        Span::styled(
236            format!("{prefix} "),
237            Style::default().fg(fg).bg(final_bg),
238        ),
239    ];
240
241    // Render content: with inline diff segments if available, otherwise syntax highlighting
242    if let Some(segments) = &line.inline_segments {
243        // Inline diff mode: render segments with emphasis on changed parts
244        let emphasis_bg = match line.line_type {
245            LineType::Added => app.theme.added_emphasis_bg,
246            LineType::Removed => app.theme.removed_emphasis_bg,
247            LineType::Context => final_bg,
248        };
249
250        for segment in segments {
251            let seg_bg = if is_selected {
252                app.theme.selection_bg
253            } else {
254                match segment.tag {
255                    SegmentTag::Changed => emphasis_bg,
256                    SegmentTag::Equal => bg,
257                }
258            };
259            let seg_modifier = if segment.tag == SegmentTag::Changed {
260                Modifier::BOLD
261            } else {
262                Modifier::empty()
263            };
264            spans.push(Span::styled(
265                segment.text.clone(),
266                Style::default().fg(fg).bg(seg_bg).add_modifier(seg_modifier),
267            ));
268        }
269    } else if let Some(highlighted) = app.highlight_cache.get(file_idx, hunk_idx, line_idx) {
270        // Syntax highlighting mode
271        for (style, text) in highlighted {
272            spans.push(Span::styled(text.clone(), style.bg(final_bg)));
273        }
274    } else {
275        // Fallback: plain text
276        spans.push(Span::styled(
277            line.content.clone(),
278            Style::default().fg(fg).bg(final_bg),
279        ));
280    }
281
282    Line::from(spans)
283}
284
285/// Compute source and target line numbers for a given line within a hunk.
286fn compute_line_numbers(
287    hunk: &crate::diff::Hunk,
288    target_line_idx: usize,
289) -> (Option<usize>, Option<usize>) {
290    let mut src_line = hunk.source_start;
291    let mut tgt_line = hunk.target_start;
292
293    for (i, line) in hunk.lines.iter().enumerate() {
294        if i == target_line_idx {
295            return match line.line_type {
296                LineType::Added => (None, Some(tgt_line)),
297                LineType::Removed => (Some(src_line), None),
298                LineType::Context => (Some(src_line), Some(tgt_line)),
299            };
300        }
301        match line.line_type {
302            LineType::Added => tgt_line += 1,
303            LineType::Removed => src_line += 1,
304            LineType::Context => {
305                src_line += 1;
306                tgt_line += 1;
307            }
308        }
309    }
310    (None, None)
311}
312
313/// Format the line number gutter (4 chars for source, 4 chars for target).
314fn format_gutter(src: Option<usize>, tgt: Option<usize>) -> String {
315    let s = src
316        .map(|n| format!("{n:>4}"))
317        .unwrap_or_else(|| "    ".to_string());
318    let t = tgt
319        .map(|n| format!("{n:>4}"))
320        .unwrap_or_else(|| "    ".to_string());
321    format!("{s} {t} ")
322}