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 crossterm::{cursor, execute, terminal};
11use inquire::ui::{Color, IndexPrefix, RenderConfig, StyleSheet, Styled};
12use similar::{ChangeTag, TextDiff};
13use std::io::{self, Write};
14
15use crate::agent::ide::{DiffResult, IdeClient};
16
17/// Safely truncate a string to a maximum number of characters (not bytes)
18/// This avoids panics when slicing UTF-8 strings with multi-byte characters
19fn truncate_str(s: &str, max_chars: usize) -> String {
20    if s.chars().count() <= max_chars {
21        s.to_string()
22    } else {
23        let truncated: String = s.chars().take(max_chars.saturating_sub(3)).collect();
24        format!("{}...", truncated)
25    }
26}
27
28/// Get custom render config for file confirmation prompts
29fn get_file_confirmation_render_config() -> RenderConfig<'static> {
30    RenderConfig::default()
31        .with_highlighted_option_prefix(Styled::new("> ").with_fg(Color::LightCyan))
32        .with_option_index_prefix(IndexPrefix::Simple)
33        .with_selected_option(Some(StyleSheet::new().with_fg(Color::LightCyan)))
34}
35
36/// Render a diff between old and new content
37pub fn render_diff(old_content: &str, new_content: &str, filename: &str) {
38    let term_width = term_size::dimensions().map(|(w, _)| w).unwrap_or(80);
39    let box_width = term_width.min(80);
40    let inner_width = box_width - 4;
41
42    // Header
43    let header = format!(" {} ", filename);
44    let header_len = header.len();
45    let left_dashes = (inner_width.saturating_sub(header_len)) / 2;
46    let right_dashes = inner_width
47        .saturating_sub(header_len)
48        .saturating_sub(left_dashes);
49
50    println!(
51        "{}{}{}{}{}",
52        "┌".dimmed(),
53        "─".repeat(left_dashes).dimmed(),
54        header.white().bold(),
55        "─".repeat(right_dashes).dimmed(),
56        "┐".dimmed()
57    );
58
59    let diff = TextDiff::from_lines(old_content, new_content);
60    let mut old_line = 1usize;
61    let mut new_line = 1usize;
62
63    for change in diff.iter_all_changes() {
64        let (line_num_display, prefix, content, style) = match change.tag() {
65            ChangeTag::Delete => {
66                let ln = format!("{:>4}", old_line);
67                old_line += 1;
68                (ln, "-", change.value().trim_end(), "red")
69            }
70            ChangeTag::Insert => {
71                let ln = format!("{:>4}", new_line);
72                new_line += 1;
73                (ln, "+", change.value().trim_end(), "green")
74            }
75            ChangeTag::Equal => {
76                let ln = format!("{:>4}", new_line);
77                old_line += 1;
78                new_line += 1;
79                (ln, " ", change.value().trim_end(), "normal")
80            }
81        };
82
83        // Truncate content if needed (using character count, not bytes)
84        let max_content_len = inner_width.saturating_sub(8); // line num + prefix + spaces
85        let truncated = truncate_str(content, max_content_len);
86
87        match style {
88            "red" => println!(
89                "{} {} {} {}",
90                "│".dimmed(),
91                line_num_display.dimmed(),
92                prefix.red().bold(),
93                truncated.red()
94            ),
95            "green" => println!(
96                "{} {} {} {}",
97                "│".dimmed(),
98                line_num_display.dimmed(),
99                prefix.green().bold(),
100                truncated.green()
101            ),
102            _ => println!(
103                "{} {} {} {}",
104                "│".dimmed(),
105                line_num_display.dimmed(),
106                prefix,
107                truncated
108            ),
109        }
110    }
111
112    // Footer
113    println!(
114        "{}{}{}",
115        "└".dimmed(),
116        "─".repeat(box_width - 2).dimmed(),
117        "┘".dimmed()
118    );
119    println!();
120
121    let _ = io::stdout().flush();
122}
123
124/// Render a new file (all additions)
125pub fn render_new_file(content: &str, filename: &str) {
126    let term_width = term_size::dimensions().map(|(w, _)| w).unwrap_or(80);
127    let box_width = term_width.min(80);
128    let inner_width = box_width - 4;
129
130    // Header with "new file" indicator
131    let header = format!(" {} (new file) ", filename);
132    let header_len = header.len();
133    let left_dashes = (inner_width.saturating_sub(header_len)) / 2;
134    let right_dashes = inner_width
135        .saturating_sub(header_len)
136        .saturating_sub(left_dashes);
137
138    println!(
139        "{}{}{}{}{}",
140        "┌".dimmed(),
141        "─".repeat(left_dashes).dimmed(),
142        header.green().bold(),
143        "─".repeat(right_dashes).dimmed(),
144        "┐".dimmed()
145    );
146
147    // Show first N lines as preview
148    const MAX_PREVIEW_LINES: usize = 20;
149    let lines: Vec<&str> = content.lines().collect();
150    let show_truncation = lines.len() > MAX_PREVIEW_LINES;
151
152    for (i, line) in lines.iter().take(MAX_PREVIEW_LINES).enumerate() {
153        let line_num = format!("{:>4}", i + 1);
154        let max_content_len = inner_width.saturating_sub(8);
155        let truncated = truncate_str(line, max_content_len);
156
157        println!(
158            "{} {} {} {}",
159            "│".dimmed(),
160            line_num.dimmed(),
161            "+".green().bold(),
162            truncated.green()
163        );
164    }
165
166    if show_truncation {
167        let remaining = lines.len() - MAX_PREVIEW_LINES;
168        println!(
169            "{} {} {} {}",
170            "│".dimmed(),
171            "    ".dimmed(),
172            "...".dimmed(),
173            format!("({} more lines)", remaining).dimmed()
174        );
175    }
176
177    // Footer
178    println!(
179        "{}{}{}",
180        "└".dimmed(),
181        "─".repeat(box_width - 2).dimmed(),
182        "┘".dimmed()
183    );
184    println!();
185
186    let _ = io::stdout().flush();
187}
188
189/// Confirm file write with diff display and optional IDE integration
190pub fn confirm_file_write(
191    path: &str,
192    old_content: Option<&str>,
193    new_content: &str,
194) -> crate::agent::ui::confirmation::ConfirmationResult {
195    use crate::agent::ui::confirmation::ConfirmationResult;
196    use inquire::{InquireError, Select, Text};
197
198    // Show terminal diff
199    match old_content {
200        Some(old) => render_diff(old, new_content, path),
201        None => render_new_file(new_content, path),
202    };
203
204    let options = vec![
205        "Yes, allow once".to_string(),
206        "Yes, allow always".to_string(),
207        "Type here to suggest changes".to_string(),
208    ];
209
210    println!("{}", "Apply this change?".white());
211
212    let selection = Select::new("", options.clone())
213        .with_render_config(get_file_confirmation_render_config())
214        .with_page_size(3)  // Show all 3 options
215        .with_help_message("↑↓ to move, Enter to select, Esc to cancel")
216        .prompt();
217
218    match selection {
219        Ok(answer) => {
220            if answer == options[0] {
221                ConfirmationResult::Proceed
222            } else if answer == options[1] {
223                // Allow always for this file pattern
224                let filename = std::path::Path::new(path)
225                    .file_name()
226                    .map(|n| n.to_string_lossy().to_string())
227                    .unwrap_or_else(|| path.to_string());
228                ConfirmationResult::ProceedAlways(filename)
229            } else {
230                // User wants to type feedback
231                println!();
232                match Text::new("What changes would you like?")
233                    .with_help_message("Press Enter to submit, Esc to cancel")
234                    .prompt()
235                {
236                    Ok(feedback) if !feedback.trim().is_empty() => {
237                        ConfirmationResult::Modify(feedback)
238                    }
239                    _ => ConfirmationResult::Cancel,
240                }
241            }
242        }
243        Err(InquireError::OperationCanceled) | Err(InquireError::OperationInterrupted) => {
244            ConfirmationResult::Cancel
245        }
246        Err(_) => ConfirmationResult::Cancel,
247    }
248}
249
250/// Confirm file write with IDE integration
251///
252/// When an IDE client is connected, this shows BOTH:
253/// 1. The diff in the IDE's native diff viewer
254/// 2. A terminal menu for confirmation
255///
256/// The user can respond from either place - first response wins.
257///
258/// # Arguments
259/// * `path` - Path to the file being modified
260/// * `old_content` - Current file content (None for new files)
261/// * `new_content` - Proposed new content
262/// * `ide_client` - Optional IDE client for native diff viewing
263///
264/// # Returns
265/// A `ConfirmationResult` indicating the user's decision
266pub async fn confirm_file_write_with_ide(
267    path: &str,
268    old_content: Option<&str>,
269    new_content: &str,
270    ide_client: Option<&IdeClient>,
271) -> crate::agent::ui::confirmation::ConfirmationResult {
272    use crate::agent::ui::confirmation::ConfirmationResult;
273    use inquire::{InquireError, Select, Text};
274    use tokio::sync::oneshot;
275
276    // Show terminal diff first
277    match old_content {
278        Some(old) => render_diff(old, new_content, path),
279        None => render_new_file(new_content, path),
280    };
281
282    // Try to open IDE diff if connected (non-blocking)
283    let ide_connected = ide_client.map(|c| c.is_connected()).unwrap_or(false);
284
285    if ide_connected {
286        let client = ide_client.unwrap();
287
288        // Convert to absolute path for IDE
289        let abs_path = std::path::Path::new(path)
290            .canonicalize()
291            .map(|p| p.to_string_lossy().to_string())
292            .unwrap_or_else(|_| path.to_string());
293
294        // Create channels for communication
295        let (terminal_tx, terminal_rx) = oneshot::channel::<ConfirmationResult>();
296        let (cancel_tx, _cancel_rx) = oneshot::channel::<()>();
297        let (menu_ready_tx, menu_ready_rx) = oneshot::channel::<()>();
298
299        // Spawn terminal input on blocking thread
300        let path_owned = path.to_string();
301        let _ide_name = client.ide_name().unwrap_or("IDE").to_string();
302        let terminal_handle = tokio::task::spawn_blocking(move || {
303            let options = vec![
304                "Yes, allow once".to_string(),
305                "Yes, allow always".to_string(),
306                "Type here to suggest changes".to_string(),
307            ];
308
309            println!("{}", "Apply this change?".white());
310
311            // Signal that the menu is about to be displayed
312            let _ = menu_ready_tx.send(());
313
314            let selection = Select::new("", options.clone())
315                .with_render_config(get_file_confirmation_render_config())
316                .with_page_size(3)
317                .with_help_message("↑↓ to move, Enter to select, Esc to cancel (or use IDE)")
318                .prompt();
319
320            let result = match selection {
321                Ok(answer) => {
322                    if answer == options[0] {
323                        ConfirmationResult::Proceed
324                    } else if answer == options[1] {
325                        let filename = std::path::Path::new(&path_owned)
326                            .file_name()
327                            .map(|n| n.to_string_lossy().to_string())
328                            .unwrap_or_else(|| path_owned.clone());
329                        ConfirmationResult::ProceedAlways(filename)
330                    } else {
331                        // User wants to type feedback
332                        println!();
333                        match Text::new("What changes would you like?")
334                            .with_help_message("Press Enter to submit, Esc to cancel")
335                            .prompt()
336                        {
337                            Ok(feedback) if !feedback.trim().is_empty() => {
338                                ConfirmationResult::Modify(feedback)
339                            }
340                            _ => ConfirmationResult::Cancel,
341                        }
342                    }
343                }
344                Err(InquireError::OperationCanceled) | Err(InquireError::OperationInterrupted) => {
345                    ConfirmationResult::Cancel
346                }
347                Err(_) => ConfirmationResult::Cancel,
348            };
349
350            let _ = terminal_tx.send(result);
351        });
352
353        // Wait for terminal menu to be ready before opening IDE diff
354        let _ = menu_ready_rx.await;
355
356        // Small delay to ensure terminal has rendered the Select prompt
357        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
358
359        // Now open IDE diff
360        let ide_future = client.open_diff(&abs_path, new_content);
361
362        // Race: first response wins
363        tokio::select! {
364            // IDE responded
365            ide_result = ide_future => {
366                // Cancel terminal input (it's blocking so we can't really cancel it,
367                // but we'll ignore its result)
368                let _ = cancel_tx.send(());
369                terminal_handle.abort();
370
371                // CRITICAL: Reset terminal state since abort() doesn't let inquire cleanup
372                // This prevents terminal corruption when rendering subsequent diffs
373                let _ = terminal::disable_raw_mode();
374                let _ = execute!(
375                    std::io::stdout(),
376                    cursor::Show,
377                    terminal::Clear(terminal::ClearType::FromCursorDown)
378                );
379                print!("\r");
380                let _ = std::io::stdout().flush();
381
382                match ide_result {
383                    Ok(DiffResult::Accepted { content: _ }) => {
384                        println!("\n{} Changes accepted in IDE", "✓".green());
385                        return ConfirmationResult::Proceed;
386                    }
387                    Ok(DiffResult::Rejected) => {
388                        println!("\n{} Changes rejected in IDE", "✗".red());
389                        return ConfirmationResult::Cancel;
390                    }
391                    Err(e) => {
392                        println!("\n{} IDE error: {}", "!".yellow(), e);
393                        // IDE failed but terminal thread is already running
394                        // Just return Cancel - user can retry
395                        return ConfirmationResult::Cancel;
396                    }
397                }
398            }
399            // Terminal responded
400            terminal_result = terminal_rx => {
401                // Close IDE diff
402                let _ = client.close_diff(&abs_path).await;
403
404                match terminal_result {
405                    Ok(result) => {
406                        match &result {
407                            ConfirmationResult::Proceed => {
408                                println!("{} Changes accepted", "✓".green());
409                            }
410                            ConfirmationResult::ProceedAlways(_) => {
411                                println!("{} Changes accepted (always for this file type)", "✓".green());
412                            }
413                            ConfirmationResult::Cancel => {
414                                println!("{} Changes cancelled", "✗".red());
415                            }
416                            ConfirmationResult::Modify(_) => {
417                                println!("{} Feedback provided", "→".cyan());
418                            }
419                        }
420                        return result;
421                    }
422                    Err(_) => {
423                        return ConfirmationResult::Cancel;
424                    }
425                }
426            }
427        }
428    }
429
430    // No IDE connection - just use terminal
431    confirm_file_write(path, old_content, new_content)
432}
433
434#[cfg(test)]
435mod tests {
436    use super::*;
437
438    #[test]
439    fn test_diff_render_doesnt_panic() {
440        let old = "line 1\nline 2\nline 3";
441        let new = "line 1\nmodified line 2\nline 3\nline 4";
442        // Just verify it doesn't panic
443        render_diff(old, new, "test.txt");
444    }
445
446    #[test]
447    fn test_new_file_render_doesnt_panic() {
448        let content = "new content\nline 2";
449        render_new_file(content, "new_file.txt");
450    }
451}