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 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 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
172fn 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
201fn 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 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 Span::styled(
231 gutter,
232 Style::default().fg(app.theme.gutter_fg).bg(final_bg),
233 ),
234 Span::styled(
236 format!("{prefix} "),
237 Style::default().fg(fg).bg(final_bg),
238 ),
239 ];
240
241 if let Some(segments) = &line.inline_segments {
243 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 for (style, text) in highlighted {
272 spans.push(Span::styled(text.clone(), style.bg(final_bg)));
273 }
274 } else {
275 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
285fn 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
313fn 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}