binocular/preview/
diff.rs1use crate::preview::{DiffPreview, PreviewContent};
2use ratatui::style::{Color, Style};
3use ratatui::text::{Line, Span, Text};
4use similar::{ChangeTag, DiffOp, TextDiff};
5use std::fs;
6use std::path::Path;
7
8pub fn build_diff_preview(left_path: &str, right_path: &str) -> PreviewContent {
9 let left = match read_text_file(left_path) {
10 Ok(content) => content,
11 Err(message) => return PreviewContent::PlainText(Text::from(message)),
12 };
13 let right = match read_text_file(right_path) {
14 Ok(content) => content,
15 Err(message) => return PreviewContent::PlainText(Text::from(message)),
16 };
17
18 let diff = TextDiff::from_lines(&left, &right);
19 let mut lines = Vec::new();
20 lines.push(Line::from(vec![
21 Span::styled("--- ", Style::default().fg(Color::Red)),
22 Span::raw(left_path.to_string()),
23 ]));
24 lines.push(Line::from(vec![
25 Span::styled("+++ ", Style::default().fg(Color::Green)),
26 Span::raw(right_path.to_string()),
27 ]));
28 lines.push(Line::default());
29
30 for group in diff.grouped_ops(3) {
31 lines.push(build_hunk_header(&group));
32 for op in group {
33 render_op_lines(&diff, &op, &mut lines);
34 }
35 }
36
37 PreviewContent::Diff(DiffPreview {
38 text: Text::from(lines),
39 })
40}
41
42const MAX_DIFF_FILE_SIZE: u64 = 50 * 1024 * 1024;
44
45fn read_text_file(path: &str) -> Result<String, String> {
46 let path_obj = Path::new(path);
47
48 let metadata = fs::metadata(path_obj)
50 .map_err(|err| format!("Failed to read {}: {}", path_obj.display(), err))?;
51 if metadata.len() > MAX_DIFF_FILE_SIZE {
52 return Err(format!(
53 "{} is too large to diff ({} > {} MiB)",
54 path_obj.display(),
55 metadata.len() / (1024 * 1024),
56 MAX_DIFF_FILE_SIZE / (1024 * 1024)
57 ));
58 }
59
60 let bytes = fs::read(path_obj)
61 .map_err(|err| format!("Failed to read {}: {}", path_obj.display(), err))?;
62
63 if bytes.contains(&0)
64 || crate::text::proportion_of_printable_ascii_characters(&bytes)
65 < crate::text::PRINTABLE_ASCII_THRESHOLD
66 {
67 return Err(format!("{} is not a text file", path_obj.display()));
68 }
69
70 if let Ok(content) = String::from_utf8(bytes.clone()) {
71 return Ok(content);
72 }
73
74 if let Some(decoded) = crate::preview::encoding::try_decode_utf16(&bytes) {
75 return Ok(decoded);
76 }
77
78 Ok(String::from_utf8_lossy(&bytes).into_owned())
79}
80
81fn build_hunk_header(group: &[DiffOp]) -> Line<'static> {
82 let old_start = group
83 .iter()
84 .map(|op| op.old_range().start)
85 .min()
86 .unwrap_or(0)
87 + 1;
88 let old_end = group.iter().map(|op| op.old_range().end).max().unwrap_or(0);
89 let new_start = group
90 .iter()
91 .map(|op| op.new_range().start)
92 .min()
93 .unwrap_or(0)
94 + 1;
95 let new_end = group.iter().map(|op| op.new_range().end).max().unwrap_or(0);
96 let old_len = old_end.saturating_sub(old_start.saturating_sub(1));
97 let new_len = new_end.saturating_sub(new_start.saturating_sub(1));
98
99 Line::from(vec![Span::styled(
100 format!(
101 "@@ -{},{} +{},{} @@",
102 old_start, old_len, new_start, new_len
103 ),
104 Style::default().fg(Color::Cyan),
105 )])
106}
107
108fn render_op_lines<'a>(
109 diff: &'a TextDiff<'a, 'a, str>,
110 op: &DiffOp,
111 lines: &mut Vec<Line<'static>>,
112) {
113 for change in diff.iter_inline_changes(op) {
114 let (sign, base_style, emphasize_style) = match change.tag() {
115 ChangeTag::Delete => (
116 "-",
117 Style::default().fg(Color::Red),
118 Style::default().fg(Color::Black).bg(Color::Red),
119 ),
120 ChangeTag::Insert => (
121 "+",
122 Style::default().fg(Color::Green),
123 Style::default().fg(Color::Black).bg(Color::Green),
124 ),
125 ChangeTag::Equal => (
126 " ",
127 Style::default().fg(Color::DarkGray),
128 Style::default().fg(Color::DarkGray),
129 ),
130 };
131
132 let old_line = format_line_number(change.old_index());
133 let new_line = format_line_number(change.new_index());
134 let gutter_style = Style::default().fg(Color::DarkGray);
135 let mut spans = vec![
136 Span::styled(format!("{} {} | ", old_line, new_line), gutter_style),
137 Span::styled(sign, base_style),
138 ];
139 for (emphasized, value) in change.iter_strings_lossy() {
140 let style = if emphasized {
141 emphasize_style
142 } else {
143 base_style
144 };
145 for segment in value.split_inclusive('\n') {
146 let segment = segment.trim_end_matches('\n');
147 if !segment.is_empty() {
148 spans.push(Span::styled(segment.to_string(), style));
149 }
150 if value.ends_with('\n') {
151 lines.push(Line::from(std::mem::take(&mut spans)));
152 spans.push(Span::styled(
153 format!("{} {} | ", old_line, new_line),
154 gutter_style,
155 ));
156 spans.push(Span::styled(sign, base_style));
157 }
158 }
159 }
160
161 if spans.len() > 1 {
162 lines.push(Line::from(spans));
163 }
164 }
165}
166
167fn format_line_number(index: Option<usize>) -> String {
168 index
169 .map(|value| format!("{:>4}", value + 1))
170 .unwrap_or_else(|| " ".to_string())
171}
172
173#[cfg(test)]
174mod tests {
175 use super::*;
176 use std::path::PathBuf;
177 use std::time::{SystemTime, UNIX_EPOCH};
178
179 fn unique_temp_path(name: &str) -> PathBuf {
180 let nanos = SystemTime::now()
181 .duration_since(UNIX_EPOCH)
182 .unwrap_or_default()
183 .as_nanos();
184 std::env::temp_dir().join(format!("binocular-diff-{name}-{nanos}.txt"))
185 }
186
187 #[test]
188 fn diff_preview_renders_changed_lines() {
189 let left = unique_temp_path("left");
190 let right = unique_temp_path("right");
191 std::fs::write(&left, "alpha\nbeta\n").unwrap();
192 std::fs::write(&right, "alpha\ngamma\n").unwrap();
193
194 let preview = build_diff_preview(&left.display().to_string(), &right.display().to_string());
195 let PreviewContent::Diff(diff) = preview else {
196 panic!("expected diff preview");
197 };
198
199 let rendered = diff
200 .text
201 .lines
202 .iter()
203 .map(|line| line.to_string())
204 .collect::<Vec<_>>()
205 .join("\n");
206 assert!(rendered.contains("--- "));
207 assert!(rendered.contains("+++ "));
208 assert!(rendered.contains("@@ -1,2 +1,2 @@"));
209 assert!(rendered.contains(" 2 | -beta") || rendered.contains(" 2 2 | -beta"));
210 assert!(rendered.contains(" 2 | +gamma") || rendered.contains(" 2 2 | +gamma"));
211
212 let _ = std::fs::remove_file(left);
213 let _ = std::fs::remove_file(right);
214 }
215}