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/// When an IDE client is connected, this shows BOTH:
245/// 1. The diff in the IDE's native diff viewer
246/// 2. A terminal menu for confirmation
247///
248/// The user can respond from either place - first response wins.
249///
250/// # Arguments
251/// * `path` - Path to the file being modified
252/// * `old_content` - Current file content (None for new files)
253/// * `new_content` - Proposed new content
254/// * `ide_client` - Optional IDE client for native diff viewing
255///
256/// # Returns
257/// A `ConfirmationResult` indicating the user's decision
258pub async fn confirm_file_write_with_ide(
259    path: &str,
260    old_content: Option<&str>,
261    new_content: &str,
262    ide_client: Option<&IdeClient>,
263) -> crate::agent::ui::confirmation::ConfirmationResult {
264    use crate::agent::ui::confirmation::ConfirmationResult;
265    use inquire::{InquireError, Select, Text};
266    use tokio::sync::oneshot;
267
268    // Show terminal diff first
269    match old_content {
270        Some(old) => render_diff(old, new_content, path),
271        None => render_new_file(new_content, path),
272    };
273
274    // Try to open IDE diff if connected (non-blocking)
275    let ide_connected = ide_client.map(|c| c.is_connected()).unwrap_or(false);
276
277    if ide_connected {
278        let client = ide_client.unwrap();
279
280        // Convert to absolute path for IDE
281        let abs_path = std::path::Path::new(path)
282            .canonicalize()
283            .map(|p| p.to_string_lossy().to_string())
284            .unwrap_or_else(|_| path.to_string());
285
286        // Create channels for communication
287        let (terminal_tx, terminal_rx) = oneshot::channel::<ConfirmationResult>();
288        let (cancel_tx, _cancel_rx) = oneshot::channel::<()>();
289        let (menu_ready_tx, menu_ready_rx) = oneshot::channel::<()>();
290
291        // Spawn terminal input on blocking thread
292        let path_owned = path.to_string();
293        let ide_name = client.ide_name().unwrap_or("IDE").to_string();
294        let terminal_handle = tokio::task::spawn_blocking(move || {
295            let options = vec![
296                "Yes, allow once".to_string(),
297                "Yes, allow always".to_string(),
298                "Type here to suggest changes".to_string(),
299            ];
300
301            println!(
302                "{} Diff opened in {} - respond here or in the IDE",
303                "→".cyan(),
304                ide_name
305            );
306            println!("{}", "Apply this change?".white());
307
308            // Signal that the menu is about to be displayed
309            let _ = menu_ready_tx.send(());
310
311            let selection = Select::new("", options.clone())
312                .with_render_config(get_file_confirmation_render_config())
313                .with_page_size(3)
314                .with_help_message("↑↓ to move, Enter to select, Esc to cancel (or use IDE)")
315                .prompt();
316
317            let result = match selection {
318                Ok(answer) => {
319                    if answer == options[0] {
320                        ConfirmationResult::Proceed
321                    } else if answer == options[1] {
322                        let filename = std::path::Path::new(&path_owned)
323                            .file_name()
324                            .map(|n| n.to_string_lossy().to_string())
325                            .unwrap_or_else(|| path_owned.clone());
326                        ConfirmationResult::ProceedAlways(filename)
327                    } else {
328                        // User wants to type feedback
329                        println!();
330                        match Text::new("What changes would you like?")
331                            .with_help_message("Press Enter to submit, Esc to cancel")
332                            .prompt()
333                        {
334                            Ok(feedback) if !feedback.trim().is_empty() => {
335                                ConfirmationResult::Modify(feedback)
336                            }
337                            _ => ConfirmationResult::Cancel,
338                        }
339                    }
340                }
341                Err(InquireError::OperationCanceled) | Err(InquireError::OperationInterrupted) => {
342                    ConfirmationResult::Cancel
343                }
344                Err(_) => ConfirmationResult::Cancel,
345            };
346
347            let _ = terminal_tx.send(result);
348        });
349
350        // Wait for terminal menu to be ready before opening IDE diff
351        let _ = menu_ready_rx.await;
352
353        // Small delay to ensure terminal has rendered the Select prompt
354        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
355
356        // Now open IDE diff
357        let ide_future = client.open_diff(&abs_path, new_content);
358
359        // Race: first response wins
360        tokio::select! {
361            // IDE responded
362            ide_result = ide_future => {
363                // Cancel terminal input (it's blocking so we can't really cancel it,
364                // but we'll ignore its result)
365                let _ = cancel_tx.send(());
366                terminal_handle.abort();
367
368                match ide_result {
369                    Ok(DiffResult::Accepted { content: _ }) => {
370                        println!("\n{} Changes accepted in IDE", "✓".green());
371                        return ConfirmationResult::Proceed;
372                    }
373                    Ok(DiffResult::Rejected) => {
374                        println!("\n{} Changes rejected in IDE", "✗".red());
375                        return ConfirmationResult::Cancel;
376                    }
377                    Err(e) => {
378                        println!("\n{} IDE error: {}", "!".yellow(), e);
379                        // IDE failed but terminal thread is already running
380                        // Just return Cancel - user can retry
381                        return ConfirmationResult::Cancel;
382                    }
383                }
384            }
385            // Terminal responded
386            terminal_result = terminal_rx => {
387                // Close IDE diff
388                let _ = client.close_diff(&abs_path).await;
389
390                match terminal_result {
391                    Ok(result) => {
392                        match &result {
393                            ConfirmationResult::Proceed => {
394                                println!("{} Changes accepted", "✓".green());
395                            }
396                            ConfirmationResult::ProceedAlways(_) => {
397                                println!("{} Changes accepted (always for this file type)", "✓".green());
398                            }
399                            ConfirmationResult::Cancel => {
400                                println!("{} Changes cancelled", "✗".red());
401                            }
402                            ConfirmationResult::Modify(_) => {
403                                println!("{} Feedback provided", "→".cyan());
404                            }
405                        }
406                        return result;
407                    }
408                    Err(_) => {
409                        return ConfirmationResult::Cancel;
410                    }
411                }
412            }
413        }
414    }
415
416    // No IDE connection - just use terminal
417    confirm_file_write(path, old_content, new_content)
418}
419
420#[cfg(test)]
421mod tests {
422    use super::*;
423
424    #[test]
425    fn test_diff_render_doesnt_panic() {
426        let old = "line 1\nline 2\nline 3";
427        let new = "line 1\nmodified line 2\nline 3\nline 4";
428        // Just verify it doesn't panic
429        render_diff(old, new, "test.txt");
430    }
431
432    #[test]
433    fn test_new_file_render_doesnt_panic() {
434        let content = "new content\nline 2";
435        render_new_file(content, "new_file.txt");
436    }
437}