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