rab/tui/components/
diff.rs1use crate::agent::ui::theme::current_theme;
2
3pub fn render_diff(diff_text: &str) -> Vec<String> {
15 let mut lines: Vec<String> = Vec::new();
16 let mut prev_removed: Option<String> = None;
17
18 for line in diff_text.lines() {
19 if line.starts_with("---") || line.starts_with("+++") || line.starts_with("@@") {
21 prev_removed = None;
22 continue;
23 }
24
25 if line.is_empty() {
26 prev_removed = None;
27 continue;
28 }
29
30 let (prefix, content) = line.split_at(1);
31 let content = content.trim_end_matches('\r');
32
33 match prefix {
34 "-" => {
35 if prev_removed.is_some() {
36 if let Some(prev) = prev_removed.take() {
38 let styled = color_line(&prev, "toolDiffRemoved");
39 lines.push(styled);
40 }
41 }
42 prev_removed = Some(line.to_string());
43 }
44 "+" => {
45 if let Some(ref removed_full) = prev_removed.take() {
46 let removed_content = &removed_full[1..]; render_intra_line_diff(removed_content, content, &mut lines);
49 } else {
50 let styled = color_line(line, "toolDiffAdded");
52 lines.push(styled);
53 }
54 }
55 _ => {
56 prev_removed = None;
57 let styled = color_line(line, "toolDiffContext");
58 lines.push(styled);
59 }
60 }
61 }
62
63 if let Some(prev) = prev_removed.take() {
65 let styled = color_line(&prev, "toolDiffRemoved");
66 lines.push(styled);
67 }
68
69 lines
70}
71
72fn color_line(line: &str, color: &str) -> String {
74 let theme = current_theme();
75 let ansi = theme.fg_ansi(color).to_string();
76 drop(theme);
77 format!("{}{}\x1b[39m", ansi, line)
78}
79
80fn render_intra_line_diff(old: &str, new: &str, output: &mut Vec<String>) {
86 let changes: Vec<Change> = compute_word_diff(old, new);
87
88 let theme = current_theme();
89 let added_ansi = theme.fg_ansi("toolDiffAdded").to_string();
90 let removed_ansi = theme.fg_ansi("toolDiffRemoved").to_string();
91 let inverse_on = "\x1b[7m"; let inverse_off = "\x1b[27m"; let reset = "\x1b[39m";
94 drop(theme);
95
96 let mut removed_line = String::new();
97 let mut added_line = String::new();
98
99 for change in &changes {
100 match change {
101 Change::Equal(text) => {
102 removed_line.push_str(text);
103 added_line.push_str(text);
104 }
105 Change::Removed(text) => {
106 let trimmed = text.trim_start();
108 if trimmed.len() < text.len() {
109 let ws = &text[..text.len() - trimmed.len()];
110 removed_line.push_str(ws);
111 }
112 removed_line.push_str(&format!("{}{}{}", inverse_on, trimmed, inverse_off));
113 }
114 Change::Added(text) => {
115 let trimmed = text.trim_start();
117 if trimmed.len() < text.len() {
118 let ws = &text[..text.len() - trimmed.len()];
119 added_line.push_str(ws);
120 }
121 added_line.push_str(&format!("{}{}{}", inverse_on, trimmed, inverse_off));
122 }
123 }
124 }
125
126 output.push(format!("-{}{}{}", removed_ansi, removed_line, reset));
127 output.push(format!("+{}{}{}", added_ansi, added_line, reset));
128}
129
130#[derive(Debug)]
132enum Change {
133 Equal(String),
134 Removed(String),
135 Added(String),
136}
137
138fn compute_word_diff(old: &str, new: &str) -> Vec<Change> {
141 let changeset = diff::chars(old, new);
142
143 let mut merged: Vec<Change> = Vec::new();
144 for change in &changeset {
145 let (tag, ch) = match change {
146 diff::Result::Left(c) => ("-", *c),
147 diff::Result::Right(c) => ("+", *c),
148 diff::Result::Both(c, _) => ("=", *c),
149 };
150
151 if let Some(last) = merged.last_mut() {
153 let last_tag = match last {
154 Change::Equal(_) => "=",
155 Change::Removed(_) => "-",
156 Change::Added(_) => "+",
157 };
158 if last_tag == tag {
159 match last {
161 Change::Equal(t) => t.push(ch),
162 Change::Removed(t) => t.push(ch),
163 Change::Added(t) => t.push(ch),
164 }
165 continue;
166 }
167 }
168
169 let change = match tag {
171 "=" => Change::Equal(ch.to_string()),
172 "-" => Change::Removed(ch.to_string()),
173 "+" => Change::Added(ch.to_string()),
174 _ => unreachable!(),
175 };
176 merged.push(change);
177 }
178
179 merged
180}
181
182#[cfg(test)]
183mod tests {
184 use super::*;
185
186 #[test]
187 fn test_empty_diff() {
188 let result = render_diff("");
189 assert!(result.is_empty());
190 }
191
192 #[test]
193 fn test_skips_headers() {
194 let diff = "--- a/file.rs\n+++ b/file.rs\n@@ -1,3 +1,4 @@\n";
195 let result = render_diff(diff);
196 assert!(result.is_empty(), "should skip all headers");
197 }
198
199 #[test]
200 fn test_context_lines() {
201 crate::agent::ui::theme::init_theme(Some("dark"), false);
202 let diff = " line1\n line2\n";
203 let result = render_diff(diff);
204 assert_eq!(result.len(), 2);
205 assert!(result[0].contains("line1"));
206 assert!(result[0].starts_with("\x1b")); assert!(result[0].contains("\x1b[39m")); }
209
210 #[test]
211 fn test_removed_line() {
212 crate::agent::ui::theme::init_theme(Some("dark"), false);
213 let diff = "-old_line\n";
214 let result = render_diff(diff);
215 assert_eq!(result.len(), 1);
216 assert!(result[0].contains('-')); assert!(result[0].contains("old_line"));
218 }
219
220 #[test]
221 fn test_added_line() {
222 crate::agent::ui::theme::init_theme(Some("dark"), false);
223 let diff = "+new_line\n";
224 let result = render_diff(diff);
225 assert_eq!(result.len(), 1);
226 assert!(result[0].contains('+'));
227 assert!(result[0].contains("new_line"));
228 }
229
230 #[test]
231 fn test_single_line_modification() {
232 crate::agent::ui::theme::init_theme(Some("dark"), false);
233 let diff = "-foo\n+bar\n";
234 let result = render_diff(diff);
235 assert_eq!(result.len(), 2);
236 assert!(result[0].contains('-'));
237 assert!(result[1].contains('+'));
238 assert!(
240 result[0].contains("\x1b[7m"),
241 "should have inverse on removed"
242 );
243 assert!(
244 result[1].contains("\x1b[7m"),
245 "should have inverse on added"
246 );
247 }
248
249 #[test]
250 fn test_multi_line_removes() {
251 crate::agent::ui::theme::init_theme(Some("dark"), false);
252 let diff = "-a\n-b\n+c\n";
253 let result = render_diff(diff);
254 assert!(result.len() >= 2);
256 assert!(result[0].contains("-a") || result[0].contains("-a"));
258 }
260
261 #[test]
262 fn test_compute_word_diff_basic() {
263 let changes = compute_word_diff("abc", "abd");
264 assert!(!changes.is_empty());
265 }
266
267 #[test]
268 fn test_compute_word_diff_identical() {
269 let changes = compute_word_diff("hello", "hello");
270 assert_eq!(changes.len(), 1);
271 assert!(matches!(changes[0], Change::Equal(_)));
272 }
273}