1use crate::highlight;
11use koda_core::preview::{
12 DeleteDirPreview, DeleteFilePreview, DiffLine, DiffPreview, DiffTag, UnifiedDiffPreview,
13 WriteNewPreview,
14};
15use ratatui::{
16 style::{Color, Modifier, Style},
17 text::{Line, Span},
18};
19
20const LINE_RED_BG: Color = Color::Rgb(50, 0, 0);
23const LINE_GREEN_BG: Color = Color::Rgb(0, 35, 0);
24const DIM: Style = Style::new().fg(Color::DarkGray);
25const HUNK_HEADER: Style = Style::new().fg(Color::Cyan);
26
27pub const GUTTER_WIDTH: u16 = 7;
30
31pub fn render_lines(preview: &DiffPreview) -> Vec<Line<'static>> {
38 match preview {
39 DiffPreview::UnifiedDiff(diff) => render_unified_diff(diff),
40 DiffPreview::WriteNew(w) => render_write_new(w),
41 DiffPreview::DeleteFile(d) => render_delete_file(d),
42 DiffPreview::DeleteDir(d) => render_delete_dir(d),
43 DiffPreview::FileNotYetExists => {
44 vec![Line::styled("(file does not exist yet)", DIM)]
45 }
46 DiffPreview::PathNotFound => {
47 vec![Line::styled("(path does not exist)", DIM)]
48 }
49 }
50}
51
52fn render_unified_diff(diff: &UnifiedDiffPreview) -> Vec<Line<'static>> {
55 let ext = std::path::Path::new(&diff.path)
56 .extension()
57 .and_then(|e| e.to_str())
58 .unwrap_or("");
59
60 let old_highlights = highlight::pre_highlight(&diff.old_content, ext);
62 let new_highlights = highlight::pre_highlight(&diff.new_content, ext);
63
64 let mut lines = Vec::new();
65
66 lines.push(Line::styled(format!("╭─── {} ───╮", diff.path), DIM));
68
69 for (i, hunk) in diff.hunks.iter().enumerate() {
70 if i > 0 {
72 lines.push(Line::styled(" ⋯", DIM));
73 }
74
75 lines.push(Line::styled(
77 format!(
78 "@@ -{},{} +{},{} @@",
79 hunk.old_start, hunk.old_count, hunk.new_start, hunk.new_count
80 ),
81 HUNK_HEADER,
82 ));
83
84 for diff_line in &hunk.lines {
86 let rendered = render_diff_line(diff_line, &old_highlights, &new_highlights);
87 lines.push(rendered);
88 }
89 }
90
91 lines.push(Line::styled(format!("╰─── {} ───╯", diff.path), DIM));
93
94 if diff.truncated {
95 lines.push(Line::styled("... diff truncated (file too large)", DIM));
96 }
97
98 lines
99}
100
101fn render_diff_line(
106 line: &DiffLine,
107 old_highlights: &[Vec<Span<'static>>],
108 new_highlights: &[Vec<Span<'static>>],
109) -> Line<'static> {
110 let (sigil, sigil_color, bg_color, highlights, line_num) = match line.tag {
111 DiffTag::Context => {
112 let num = line.old_line.unwrap_or(0);
113 (' ', Color::DarkGray, None, old_highlights, num)
114 }
115 DiffTag::Delete => {
116 let num = line.old_line.unwrap_or(0);
117 ('-', Color::Red, Some(LINE_RED_BG), old_highlights, num)
118 }
119 DiffTag::Insert => {
120 let num = line.new_line.unwrap_or(0);
121 ('+', Color::Green, Some(LINE_GREEN_BG), new_highlights, num)
122 }
123 };
124
125 let mut spans = Vec::new();
126
127 let gutter_style = Style::default().fg(sigil_color).add_modifier(Modifier::DIM);
129 spans.push(Span::styled(
130 format!("{:>4} {} ", line_num, sigil),
131 gutter_style,
132 ));
133
134 let idx = line_num.saturating_sub(1); if idx < highlights.len() {
137 for hl_span in &highlights[idx] {
138 let mut style = hl_span.style;
139 if let Some(bg) = bg_color {
140 style = style.bg(bg);
141 }
142 spans.push(Span::styled(hl_span.content.clone(), style));
143 }
144 } else {
145 let style = match bg_color {
147 Some(bg) => Style::default().bg(bg),
148 None => Style::default(),
149 };
150 spans.push(Span::styled(line.content.clone(), style));
151 }
152
153 Line::from(spans)
154}
155
156fn render_write_new(w: &WriteNewPreview) -> Vec<Line<'static>> {
159 let ext = std::path::Path::new(&w.path)
160 .extension()
161 .and_then(|e| e.to_str())
162 .unwrap_or("");
163
164 let mut lines = vec![Line::styled(
165 format!(
166 "╭─── {} (new file: {} lines, {} bytes) ───╮",
167 w.path, w.line_count, w.byte_count
168 ),
169 DIM,
170 )];
171
172 let mut hl = crate::highlight::CodeHighlighter::new(ext);
173 for (i, content) in w.first_lines.iter().enumerate() {
174 let mut spans = vec![Span::styled(
175 format!("{:>4} + ", i + 1),
176 Style::default()
177 .fg(Color::Green)
178 .add_modifier(Modifier::DIM),
179 )];
180 let highlighted = hl.highlight_spans(content);
181 for mut s in highlighted {
182 s.style = s.style.bg(LINE_GREEN_BG);
183 spans.push(s);
184 }
185 lines.push(Line::from(spans));
186 }
187
188 if w.truncated {
189 lines.push(Line::styled(
190 format!("... +{} more lines", w.line_count - w.first_lines.len()),
191 DIM,
192 ));
193 }
194
195 lines.push(Line::styled(format!("╰─── {} ───╯", w.path), DIM));
196
197 lines
198}
199
200fn render_delete_file(d: &DeleteFilePreview) -> Vec<Line<'static>> {
203 vec![Line::styled(
204 format!("Removing {} lines ({} bytes)", d.line_count, d.byte_count),
205 Style::default().bg(LINE_RED_BG),
206 )]
207}
208
209fn render_delete_dir(d: &DeleteDirPreview) -> Vec<Line<'static>> {
210 if d.recursive {
211 vec![Line::styled(
212 "Removing directory and all contents",
213 Style::default().bg(LINE_RED_BG),
214 )]
215 } else {
216 vec![Line::styled(
217 "Removing empty directory",
218 Style::default().bg(LINE_RED_BG),
219 )]
220 }
221}
222
223#[cfg(test)]
224mod tests {
225 use super::*;
226 use koda_core::preview::*;
227
228 #[test]
229 fn test_unified_diff_has_hunk_headers() {
230 let preview = DiffPreview::UnifiedDiff(UnifiedDiffPreview {
231 path: "test.rs".into(),
232 old_content: "fn main() {\n println!(\"hello\");\n}\n".into(),
233 new_content: "fn main() {\n println!(\"world\");\n}\n".into(),
234 hunks: vec![DiffHunk {
235 old_start: 1,
236 old_count: 3,
237 new_start: 1,
238 new_count: 3,
239 lines: vec![
240 DiffLine {
241 tag: DiffTag::Context,
242 content: "fn main() {".into(),
243 old_line: Some(1),
244 new_line: Some(1),
245 },
246 DiffLine {
247 tag: DiffTag::Delete,
248 content: " println!(\"hello\");".into(),
249 old_line: Some(2),
250 new_line: None,
251 },
252 DiffLine {
253 tag: DiffTag::Insert,
254 content: " println!(\"world\");".into(),
255 old_line: None,
256 new_line: Some(2),
257 },
258 DiffLine {
259 tag: DiffTag::Context,
260 content: "}".into(),
261 old_line: Some(3),
262 new_line: Some(3),
263 },
264 ],
265 }],
266 truncated: false,
267 });
268
269 let lines = render_lines(&preview);
270 let text: Vec<String> = lines
271 .iter()
272 .map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
273 .collect();
274
275 assert!(text[0].contains("test.rs"), "header: {}", text[0]);
277 assert!(
279 text.iter().any(|t| t.contains("@@")),
280 "should have hunk header"
281 );
282 assert!(
284 text.iter().any(|t| t.contains(" - ")),
285 "should have delete marker"
286 );
287 assert!(
288 text.iter().any(|t| t.contains(" + ")),
289 "should have insert marker"
290 );
291 }
292
293 #[test]
294 fn test_write_new_rendering() {
295 let preview = DiffPreview::WriteNew(WriteNewPreview {
296 path: "new.rs".into(),
297 line_count: 10,
298 byte_count: 200,
299 first_lines: vec!["fn main() {}".into()],
300 truncated: true,
301 });
302 let lines = render_lines(&preview);
303 let text: Vec<String> = lines
304 .iter()
305 .map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
306 .collect();
307 assert!(text[0].contains("new.rs"));
308 assert!(text.iter().any(|t| t.contains("more lines")));
309 }
310
311 #[test]
312 fn test_hunk_separator_between_hunks() {
313 let preview = DiffPreview::UnifiedDiff(UnifiedDiffPreview {
314 path: "test.rs".into(),
315 old_content: String::new(),
316 new_content: String::new(),
317 hunks: vec![
318 DiffHunk {
319 old_start: 1,
320 old_count: 1,
321 new_start: 1,
322 new_count: 1,
323 lines: vec![DiffLine {
324 tag: DiffTag::Context,
325 content: "a".into(),
326 old_line: Some(1),
327 new_line: Some(1),
328 }],
329 },
330 DiffHunk {
331 old_start: 50,
332 old_count: 1,
333 new_start: 50,
334 new_count: 1,
335 lines: vec![DiffLine {
336 tag: DiffTag::Context,
337 content: "b".into(),
338 old_line: Some(50),
339 new_line: Some(50),
340 }],
341 },
342 ],
343 truncated: false,
344 });
345 let lines = render_lines(&preview);
346 let text: Vec<String> = lines
347 .iter()
348 .map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
349 .collect();
350 assert!(
351 text.iter().any(|t| t.contains('⋯')),
352 "should have hunk separator"
353 );
354 }
355}