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!(
310                "{} Diff opened in {} - respond here or in the IDE",
311                "→".cyan(),
312                ide_name
313            );
314            println!("{}", "Apply this change?".white());
315
316            // Signal that the menu is about to be displayed
317            let _ = menu_ready_tx.send(());
318
319            let selection = Select::new("", options.clone())
320                .with_render_config(get_file_confirmation_render_config())
321                .with_page_size(3)
322                .with_help_message("↑↓ to move, Enter to select, Esc to cancel (or use IDE)")
323                .prompt();
324
325            let result = match selection {
326                Ok(answer) => {
327                    if answer == options[0] {
328                        ConfirmationResult::Proceed
329                    } else if answer == options[1] {
330                        let filename = std::path::Path::new(&path_owned)
331                            .file_name()
332                            .map(|n| n.to_string_lossy().to_string())
333                            .unwrap_or_else(|| path_owned.clone());
334                        ConfirmationResult::ProceedAlways(filename)
335                    } else {
336                        // User wants to type feedback
337                        println!();
338                        match Text::new("What changes would you like?")
339                            .with_help_message("Press Enter to submit, Esc to cancel")
340                            .prompt()
341                        {
342                            Ok(feedback) if !feedback.trim().is_empty() => {
343                                ConfirmationResult::Modify(feedback)
344                            }
345                            _ => ConfirmationResult::Cancel,
346                        }
347                    }
348                }
349                Err(InquireError::OperationCanceled) | Err(InquireError::OperationInterrupted) => {
350                    ConfirmationResult::Cancel
351                }
352                Err(_) => ConfirmationResult::Cancel,
353            };
354
355            let _ = terminal_tx.send(result);
356        });
357
358        // Wait for terminal menu to be ready before opening IDE diff
359        let _ = menu_ready_rx.await;
360
361        // Small delay to ensure terminal has rendered the Select prompt
362        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
363
364        // Now open IDE diff
365        let ide_future = client.open_diff(&abs_path, new_content);
366
367        // Race: first response wins
368        tokio::select! {
369            // IDE responded
370            ide_result = ide_future => {
371                // Cancel terminal input (it's blocking so we can't really cancel it,
372                // but we'll ignore its result)
373                let _ = cancel_tx.send(());
374                terminal_handle.abort();
375
376                // CRITICAL: Reset terminal state since abort() doesn't let inquire cleanup
377                // This prevents terminal corruption when rendering subsequent diffs
378                let _ = terminal::disable_raw_mode();
379                let _ = execute!(
380                    std::io::stdout(),
381                    cursor::Show,
382                    terminal::Clear(terminal::ClearType::FromCursorDown)
383                );
384                print!("\r");
385                let _ = std::io::stdout().flush();
386
387                match ide_result {
388                    Ok(DiffResult::Accepted { content: _ }) => {
389                        println!("\n{} Changes accepted in IDE", "✓".green());
390                        return ConfirmationResult::Proceed;
391                    }
392                    Ok(DiffResult::Rejected) => {
393                        println!("\n{} Changes rejected in IDE", "✗".red());
394                        return ConfirmationResult::Cancel;
395                    }
396                    Err(e) => {
397                        println!("\n{} IDE error: {}", "!".yellow(), e);
398                        // IDE failed but terminal thread is already running
399                        // Just return Cancel - user can retry
400                        return ConfirmationResult::Cancel;
401                    }
402                }
403            }
404            // Terminal responded
405            terminal_result = terminal_rx => {
406                // Close IDE diff
407                let _ = client.close_diff(&abs_path).await;
408
409                match terminal_result {
410                    Ok(result) => {
411                        match &result {
412                            ConfirmationResult::Proceed => {
413                                println!("{} Changes accepted", "✓".green());
414                            }
415                            ConfirmationResult::ProceedAlways(_) => {
416                                println!("{} Changes accepted (always for this file type)", "✓".green());
417                            }
418                            ConfirmationResult::Cancel => {
419                                println!("{} Changes cancelled", "✗".red());
420                            }
421                            ConfirmationResult::Modify(_) => {
422                                println!("{} Feedback provided", "→".cyan());
423                            }
424                        }
425                        return result;
426                    }
427                    Err(_) => {
428                        return ConfirmationResult::Cancel;
429                    }
430                }
431            }
432        }
433    }
434
435    // No IDE connection - just use terminal
436    confirm_file_write(path, old_content, new_content)
437}
438
439#[cfg(test)]
440mod tests {
441    use super::*;
442
443    #[test]
444    fn test_diff_render_doesnt_panic() {
445        let old = "line 1\nline 2\nline 3";
446        let new = "line 1\nmodified line 2\nline 3\nline 4";
447        // Just verify it doesn't panic
448        render_diff(old, new, "test.txt");
449    }
450
451    #[test]
452    fn test_new_file_render_doesnt_panic() {
453        let content = "new content\nline 2";
454        render_new_file(content, "new_file.txt");
455    }
456}