syncable_cli/agent/ui/
diff.rs

1//! Diff rendering for file change confirmation
2//!
3//! Provides visual diff display for file modifications, showing
4//! additions in green and deletions in red with line numbers.
5//!
6//! When an IDE companion extension is connected, diffs can be shown
7//! in the IDE's native diff viewer for a better experience.
8
9use colored::Colorize;
10use inquire::ui::{Color, IndexPrefix, RenderConfig, StyleSheet, Styled};
11use similar::{ChangeTag, TextDiff};
12use std::io::{self, Write};
13
14use crate::agent::ide::{DiffResult, IdeClient};
15
16/// Get custom render config for file confirmation prompts
17fn get_file_confirmation_render_config() -> RenderConfig<'static> {
18    RenderConfig::default()
19        .with_highlighted_option_prefix(Styled::new("> ").with_fg(Color::LightCyan))
20        .with_option_index_prefix(IndexPrefix::Simple)
21        .with_selected_option(Some(StyleSheet::new().with_fg(Color::LightCyan)))
22}
23
24/// Render a diff between old and new content
25pub fn render_diff(old_content: &str, new_content: &str, filename: &str) {
26    let term_width = term_size::dimensions().map(|(w, _)| w).unwrap_or(80);
27    let box_width = term_width.min(80);
28    let inner_width = box_width - 4;
29
30    // Header
31    let header = format!(" {} ", filename);
32    let header_len = header.len();
33    let left_dashes = (inner_width.saturating_sub(header_len)) / 2;
34    let right_dashes = inner_width.saturating_sub(header_len).saturating_sub(left_dashes);
35
36    println!(
37        "{}{}{}{}{}",
38        "┌".dimmed(),
39        "─".repeat(left_dashes).dimmed(),
40        header.white().bold(),
41        "─".repeat(right_dashes).dimmed(),
42        "┐".dimmed()
43    );
44
45    let diff = TextDiff::from_lines(old_content, new_content);
46    let mut old_line = 1usize;
47    let mut new_line = 1usize;
48
49    for change in diff.iter_all_changes() {
50        let (line_num_display, prefix, content, style) = match change.tag() {
51            ChangeTag::Delete => {
52                let ln = format!("{:>4}", old_line);
53                old_line += 1;
54                (ln, "-", change.value().trim_end(), "red")
55            }
56            ChangeTag::Insert => {
57                let ln = format!("{:>4}", new_line);
58                new_line += 1;
59                (ln, "+", change.value().trim_end(), "green")
60            }
61            ChangeTag::Equal => {
62                let ln = format!("{:>4}", new_line);
63                old_line += 1;
64                new_line += 1;
65                (ln, " ", change.value().trim_end(), "normal")
66            }
67        };
68
69        // Truncate content if needed
70        let max_content_len = inner_width.saturating_sub(8); // line num + prefix + spaces
71        let truncated = if content.len() > max_content_len {
72            format!("{}...", &content[..max_content_len.saturating_sub(3)])
73        } else {
74            content.to_string()
75        };
76
77        match style {
78            "red" => println!(
79                "{} {} {} {}",
80                "│".dimmed(),
81                line_num_display.dimmed(),
82                prefix.red().bold(),
83                truncated.red()
84            ),
85            "green" => println!(
86                "{} {} {} {}",
87                "│".dimmed(),
88                line_num_display.dimmed(),
89                prefix.green().bold(),
90                truncated.green()
91            ),
92            _ => println!(
93                "{} {} {} {}",
94                "│".dimmed(),
95                line_num_display.dimmed(),
96                prefix,
97                truncated
98            ),
99        }
100    }
101
102    // Footer
103    println!(
104        "{}{}{}",
105        "└".dimmed(),
106        "─".repeat(box_width - 2).dimmed(),
107        "┘".dimmed()
108    );
109    println!();
110
111    let _ = io::stdout().flush();
112}
113
114/// Render a new file (all additions)
115pub fn render_new_file(content: &str, filename: &str) {
116    let term_width = term_size::dimensions().map(|(w, _)| w).unwrap_or(80);
117    let box_width = term_width.min(80);
118    let inner_width = box_width - 4;
119
120    // Header with "new file" indicator
121    let header = format!(" {} (new file) ", filename);
122    let header_len = header.len();
123    let left_dashes = (inner_width.saturating_sub(header_len)) / 2;
124    let right_dashes = inner_width.saturating_sub(header_len).saturating_sub(left_dashes);
125
126    println!(
127        "{}{}{}{}{}",
128        "┌".dimmed(),
129        "─".repeat(left_dashes).dimmed(),
130        header.green().bold(),
131        "─".repeat(right_dashes).dimmed(),
132        "┐".dimmed()
133    );
134
135    // Show first N lines as preview
136    const MAX_PREVIEW_LINES: usize = 20;
137    let lines: Vec<&str> = content.lines().collect();
138    let show_truncation = lines.len() > MAX_PREVIEW_LINES;
139
140    for (i, line) in lines.iter().take(MAX_PREVIEW_LINES).enumerate() {
141        let line_num = format!("{:>4}", i + 1);
142        let max_content_len = inner_width.saturating_sub(8);
143        let truncated = if line.len() > max_content_len {
144            format!("{}...", &line[..max_content_len.saturating_sub(3)])
145        } else {
146            line.to_string()
147        };
148
149        println!(
150            "{} {} {} {}",
151            "│".dimmed(),
152            line_num.dimmed(),
153            "+".green().bold(),
154            truncated.green()
155        );
156    }
157
158    if show_truncation {
159        let remaining = lines.len() - MAX_PREVIEW_LINES;
160        println!(
161            "{} {} {} {}",
162            "│".dimmed(),
163            "    ".dimmed(),
164            "...".dimmed(),
165            format!("({} more lines)", remaining).dimmed()
166        );
167    }
168
169    // Footer
170    println!(
171        "{}{}{}",
172        "└".dimmed(),
173        "─".repeat(box_width - 2).dimmed(),
174        "┘".dimmed()
175    );
176    println!();
177
178    let _ = io::stdout().flush();
179}
180
181/// Confirm file write with diff display and optional IDE integration
182pub fn confirm_file_write(
183    path: &str,
184    old_content: Option<&str>,
185    new_content: &str,
186) -> crate::agent::ui::confirmation::ConfirmationResult {
187    use crate::agent::ui::confirmation::ConfirmationResult;
188    use inquire::{InquireError, Select, Text};
189
190    // Show terminal diff
191    match old_content {
192        Some(old) => render_diff(old, new_content, path),
193        None => render_new_file(new_content, path),
194    };
195
196    let options = vec![
197        "Yes, allow once".to_string(),
198        "Yes, allow always".to_string(),
199        "Type here to suggest changes".to_string(),
200    ];
201
202    println!("{}", "Apply this change?".white());
203
204    let selection = Select::new("", options.clone())
205        .with_render_config(get_file_confirmation_render_config())
206        .with_page_size(3)  // Show all 3 options
207        .with_help_message("↑↓ to move, Enter to select, Esc to cancel")
208        .prompt();
209
210    match selection {
211        Ok(answer) => {
212            if answer == options[0] {
213                ConfirmationResult::Proceed
214            } else if answer == options[1] {
215                // Allow always for this file pattern
216                let filename = std::path::Path::new(path)
217                    .file_name()
218                    .map(|n| n.to_string_lossy().to_string())
219                    .unwrap_or_else(|| path.to_string());
220                ConfirmationResult::ProceedAlways(filename)
221            } else {
222                // User wants to type feedback
223                println!();
224                match Text::new("What changes would you like?")
225                    .with_help_message("Press Enter to submit, Esc to cancel")
226                    .prompt()
227                {
228                    Ok(feedback) if !feedback.trim().is_empty() => {
229                        ConfirmationResult::Modify(feedback)
230                    }
231                    _ => ConfirmationResult::Cancel,
232                }
233            }
234        }
235        Err(InquireError::OperationCanceled) | Err(InquireError::OperationInterrupted) => {
236            ConfirmationResult::Cancel
237        }
238        Err(_) => ConfirmationResult::Cancel,
239    }
240}
241
242/// Confirm file write with IDE integration
243///
244/// If an IDE client is connected, the diff will be shown in the IDE's native
245/// diff viewer. Otherwise, falls back to terminal diff display.
246///
247/// # Arguments
248/// * `path` - Path to the file being modified
249/// * `old_content` - Current file content (None for new files)
250/// * `new_content` - Proposed new content
251/// * `ide_client` - Optional IDE client for native diff viewing
252///
253/// # Returns
254/// A `ConfirmationResult` indicating the user's decision
255pub async fn confirm_file_write_with_ide(
256    path: &str,
257    old_content: Option<&str>,
258    new_content: &str,
259    ide_client: Option<&IdeClient>,
260) -> crate::agent::ui::confirmation::ConfirmationResult {
261    use crate::agent::ui::confirmation::ConfirmationResult;
262
263    // Try IDE diff first if connected
264    if let Some(client) = ide_client {
265        if client.is_connected() {
266            // Convert to absolute path for IDE
267            let abs_path = std::path::Path::new(path)
268                .canonicalize()
269                .map(|p| p.to_string_lossy().to_string())
270                .unwrap_or_else(|_| path.to_string());
271
272            println!(
273                "{} Opening diff in {}...",
274                "→".cyan(),
275                client.ide_name().unwrap_or("IDE")
276            );
277
278            match client.open_diff(&abs_path, new_content).await {
279                Ok(DiffResult::Accepted { content: _ }) => {
280                    println!("{} Changes accepted in IDE", "✓".green());
281                    return ConfirmationResult::Proceed;
282                }
283                Ok(DiffResult::Rejected) => {
284                    println!("{} Changes rejected in IDE", "✗".red());
285                    return ConfirmationResult::Cancel;
286                }
287                Err(e) => {
288                    // Fall through to terminal diff
289                    println!(
290                        "{} IDE diff failed ({}), showing terminal diff...",
291                        "!".yellow(),
292                        e
293                    );
294                }
295            }
296        }
297    }
298
299    // Fallback: terminal diff and confirmation
300    confirm_file_write(path, old_content, new_content)
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306
307    #[test]
308    fn test_diff_render_doesnt_panic() {
309        let old = "line 1\nline 2\nline 3";
310        let new = "line 1\nmodified line 2\nline 3\nline 4";
311        // Just verify it doesn't panic
312        render_diff(old, new, "test.txt");
313    }
314
315    #[test]
316    fn test_new_file_render_doesnt_panic() {
317        let content = "new content\nline 2";
318        render_new_file(content, "new_file.txt");
319    }
320}