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