Skip to main content

rab/tui/components/
diff.rs

1use crate::agent::ui::theme::current_theme;
2
3/// Render a unified diff string with colored lines and intra-line change highlighting.
4/// Matches pi's `renderDiff()` in `diff.ts`.
5///
6/// Input format (from edit tool's compute_diff):
7/// `--- a/path` / `+++ b/path` / `@@ -1,5 +1,6 @@` / ` context` / `-removed` / `+added`
8///
9/// Output: ANSI-styled lines with:
10/// - `-` lines: `toolDiffRemoved` (red)
11/// - `+` lines: `toolDiffAdded` (green)
12/// - ` ` lines: `toolDiffContext` (gray)
13/// - Single-line changes: intra-line diff with inverse highlighting
14pub 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        // Skip unified diff headers
20        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                    // Multiple removed lines — push previous and start new
37                    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..]; // strip '-'
47                    // Intra-line diff: single removed + single added
48                    render_intra_line_diff(removed_content, content, &mut lines);
49                } else {
50                    // Standalone added line (multiple adds after removes)
51                    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    // Flush remaining removed line
64    if let Some(prev) = prev_removed.take() {
65        let styled = color_line(&prev, "toolDiffRemoved");
66        lines.push(styled);
67    }
68
69    lines
70}
71
72/// Color a single diff line with the given theme color.
73fn 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
80/// Render intra-line diff for a single-line change (one removed, one added).
81/// Uses character-level diff and applies inverse (reverse video) on changed parts.
82///
83/// Matches pi's `renderIntraLineDiff()` which uses `diffWords` to find changed
84/// tokens and applies `theme.inverse()` on them.
85fn 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"; // reverse video
92    let inverse_off = "\x1b[27m"; // reverse video off
93    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                // Strip leading whitespace (matching pi's behavior)
107                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                // Strip leading whitespace
116                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/// A change in a diff: equal, removed, or added.
131#[derive(Debug)]
132enum Change {
133    Equal(String),
134    Removed(String),
135    Added(String),
136}
137
138/// Compute a character-level diff between two strings.
139/// Groups consecutive same-type changes into tokens (matching pi's diffWords style).
140fn 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        // Try to merge with the last change
152        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                // Merge character into last change
160                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        // New change group
170        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")); // has ANSI color
207        assert!(result[0].contains("\x1b[39m")); // has reset
208    }
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('-')); // prefix preserved
217        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        // Intra-line diff should have inverse markers
239        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        // Two removed lines, then one added
255        assert!(result.len() >= 2);
256        // The first two should be - lines
257        assert!(result[0].contains("-a") || result[0].contains("-a"));
258        // The last should be a + line or intra-line diff
259    }
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}