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
8pub 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 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
41fn 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
62fn 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 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
161fn 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
190fn 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 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 Span::styled(
220 gutter,
221 Style::default().fg(app.theme.gutter_fg).bg(final_bg),
222 ),
223 Span::styled(
225 format!("{prefix} "),
226 Style::default().fg(fg).bg(final_bg),
227 ),
228 ];
229
230 if let Some(segments) = &line.inline_segments {
232 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 for (style, text) in highlighted {
261 spans.push(Span::styled(text.clone(), style.bg(final_bg)));
262 }
263 } else {
264 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
274fn 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
302fn 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}