code_mesh_tui/
diff.rs

1use anyhow::Result;
2use ratatui::{
3    layout::Rect,
4    widgets::{Block, Borders, Paragraph},
5    style::Style,
6    text::{Line, Span},
7};
8
9use crate::{
10    renderer::Renderer,
11    theme::Theme,
12    config::DiffStyle,
13};
14
15/// Diff viewer component for showing file differences
16pub struct DiffViewer {
17    theme: Box<dyn Theme + Send + Sync>,
18    current_diff: Option<DiffContent>,
19    style: DiffStyle,
20    scroll_offset: usize,
21}
22
23/// Diff content representation
24#[derive(Debug, Clone)]
25pub struct DiffContent {
26    pub original_file: String,
27    pub modified_file: String,
28    pub hunks: Vec<DiffHunk>,
29}
30
31/// A diff hunk representing a section of changes
32#[derive(Debug, Clone)]
33pub struct DiffHunk {
34    pub original_start: usize,
35    pub original_count: usize,
36    pub modified_start: usize,
37    pub modified_count: usize,
38    pub lines: Vec<DiffLine>,
39}
40
41/// A line in a diff
42#[derive(Debug, Clone)]
43pub struct DiffLine {
44    pub line_type: DiffLineType,
45    pub content: String,
46    pub original_line_number: Option<usize>,
47    pub modified_line_number: Option<usize>,
48}
49
50/// Type of diff line
51#[derive(Debug, Clone, PartialEq)]
52pub enum DiffLineType {
53    Context,  // Unchanged line
54    Added,    // Added line (+)
55    Removed,  // Removed line (-)
56    Header,   // Diff header
57}
58
59impl DiffViewer {
60    /// Create a new diff viewer
61    pub fn new(theme: &dyn Theme) -> Self {
62        Self {
63            theme: Box::new(crate::theme::DefaultTheme), // Temporary
64            current_diff: None,
65            style: DiffStyle::SideBySide,
66            scroll_offset: 0,
67        }
68    }
69    
70    /// Load diff content from a string
71    pub fn load_diff(&mut self, diff_text: &str) -> Result<()> {
72        let diff_content = self.parse_diff(diff_text)?;
73        self.current_diff = Some(diff_content);
74        self.scroll_offset = 0;
75        Ok(())
76    }
77    
78    /// Set the diff style
79    pub fn set_style(&mut self, style: DiffStyle) {
80        self.style = style;
81    }
82    
83    /// Toggle between unified and side-by-side view
84    pub fn toggle_style(&mut self) {
85        self.style = match self.style {
86            DiffStyle::Unified => DiffStyle::SideBySide,
87            DiffStyle::SideBySide => DiffStyle::Unified,
88        };
89    }
90    
91    /// Scroll up
92    pub fn scroll_up(&mut self) {
93        if self.scroll_offset > 0 {
94            self.scroll_offset -= 1;
95        }
96    }
97    
98    /// Scroll down
99    pub fn scroll_down(&mut self) {
100        if let Some(ref diff) = self.current_diff {
101            let total_lines = diff.hunks.iter().map(|h| h.lines.len()).sum::<usize>();
102            if self.scroll_offset < total_lines.saturating_sub(1) {
103                self.scroll_offset += 1;
104            }
105        }
106    }
107    
108    /// Parse unified diff format
109    fn parse_diff(&self, diff_text: &str) -> Result<DiffContent> {
110        let mut hunks = Vec::new();
111        let mut current_hunk: Option<DiffHunk> = None;
112        let mut original_file = String::new();
113        let mut modified_file = String::new();
114        
115        for line in diff_text.lines() {
116            if line.starts_with("--- ") {
117                original_file = line[4..].to_string();
118            } else if line.starts_with("+++ ") {
119                modified_file = line[4..].to_string();
120            } else if line.starts_with("@@ ") {
121                // Save previous hunk if it exists
122                if let Some(hunk) = current_hunk.take() {
123                    hunks.push(hunk);
124                }
125                
126                // Parse hunk header
127                let hunk = self.parse_hunk_header(line)?;
128                current_hunk = Some(hunk);
129            } else if let Some(ref mut hunk) = current_hunk {
130                // Parse diff line
131                let diff_line = self.parse_diff_line(line, &hunk.lines)?;
132                hunk.lines.push(diff_line);
133            }
134        }
135        
136        // Add the last hunk
137        if let Some(hunk) = current_hunk {
138            hunks.push(hunk);
139        }
140        
141        Ok(DiffContent {
142            original_file,
143            modified_file,
144            hunks,
145        })
146    }
147    
148    /// Parse a hunk header line (e.g., "@@ -1,4 +1,6 @@")
149    fn parse_hunk_header(&self, line: &str) -> Result<DiffHunk> {
150        // Simple regex-like parsing for "@@ -start,count +start,count @@"
151        let parts: Vec<&str> = line.split_whitespace().collect();
152        if parts.len() < 3 {
153            return Err(anyhow::anyhow!("Invalid hunk header: {}", line));
154        }
155        
156        let original_part = parts[1];
157        let modified_part = parts[2];
158        
159        let (original_start, original_count) = self.parse_range(original_part)?;
160        let (modified_start, modified_count) = self.parse_range(modified_part)?;
161        
162        Ok(DiffHunk {
163            original_start,
164            original_count,
165            modified_start,
166            modified_count,
167            lines: Vec::new(),
168        })
169    }
170    
171    /// Parse a range like "-1,4" or "+1,6"
172    fn parse_range(&self, range: &str) -> Result<(usize, usize)> {
173        let range = &range[1..]; // Remove +/- prefix
174        
175        if let Some(comma_pos) = range.find(',') {
176            let start = range[..comma_pos].parse::<usize>()?;
177            let count = range[comma_pos + 1..].parse::<usize>()?;
178            Ok((start, count))
179        } else {
180            let start = range.parse::<usize>()?;
181            Ok((start, 1))
182        }
183    }
184    
185    /// Parse a diff line
186    fn parse_diff_line(&self, line: &str, existing_lines: &[DiffLine]) -> Result<DiffLine> {
187        if line.is_empty() {
188            return Ok(DiffLine {
189                line_type: DiffLineType::Context,
190                content: String::new(),
191                original_line_number: None,
192                modified_line_number: None,
193            });
194        }
195        
196        let line_type = match line.chars().next().unwrap_or(' ') {
197            '+' => DiffLineType::Added,
198            '-' => DiffLineType::Removed,
199            ' ' => DiffLineType::Context,
200            _ => DiffLineType::Header,
201        };
202        
203        let content = if line.len() > 1 {
204            line[1..].to_string()
205        } else {
206            String::new()
207        };
208        
209        // Calculate line numbers (simplified)
210        let (original_line_number, modified_line_number) = match line_type {
211            DiffLineType::Added => (None, Some(existing_lines.len() + 1)),
212            DiffLineType::Removed => (Some(existing_lines.len() + 1), None),
213            DiffLineType::Context => (Some(existing_lines.len() + 1), Some(existing_lines.len() + 1)),
214            DiffLineType::Header => (None, None),
215        };
216        
217        Ok(DiffLine {
218            line_type,
219            content,
220            original_line_number,
221            modified_line_number,
222        })
223    }
224    
225    /// Render the diff viewer
226    pub fn render(&self, renderer: &Renderer, area: Rect) {
227        let title = if let Some(ref diff) = self.current_diff {
228            format!("Diff: {} → {}", diff.original_file, diff.modified_file)
229        } else {
230            "No diff loaded".to_string()
231        };
232        
233        let block = Block::default()
234            .title(title)
235            .borders(Borders::ALL)
236            .border_style(Style::default().fg(self.theme.border()));
237        
238        renderer.render_widget(block.clone(), area);
239        
240        let inner_area = block.inner(area);
241        
242        if let Some(ref diff) = self.current_diff {
243            match self.style {
244                DiffStyle::Unified => self.render_unified(renderer, inner_area, diff),
245                DiffStyle::SideBySide => self.render_side_by_side(renderer, inner_area, diff),
246            }
247        } else {
248            let empty_msg = Paragraph::new("No diff loaded")
249                .style(Style::default().fg(self.theme.text_muted()));
250            renderer.render_widget(empty_msg, inner_area);
251        }
252    }
253    
254    /// Render unified diff view
255    fn render_unified(&self, renderer: &Renderer, area: Rect, diff: &DiffContent) {
256        let mut lines = Vec::new();
257        
258        // Collect all lines from all hunks
259        let mut all_lines = Vec::new();
260        for hunk in &diff.hunks {
261            for line in &hunk.lines {
262                all_lines.push(line);
263            }
264        }
265        
266        // Show visible portion
267        let visible_height = area.height as usize;
268        let start_line = self.scroll_offset;
269        let end_line = (start_line + visible_height).min(all_lines.len());
270        let visible_lines = &all_lines[start_line..end_line];
271        
272        for line in visible_lines {
273            let (prefix, style) = match line.line_type {
274                DiffLineType::Added => ("+", Style::default().fg(self.theme.diff_added())),
275                DiffLineType::Removed => ("-", Style::default().fg(self.theme.diff_removed())),
276                DiffLineType::Context => (" ", Style::default().fg(self.theme.text())),
277                DiffLineType::Header => ("@", Style::default().fg(self.theme.accent())),
278            };
279            
280            let formatted_line = Line::from(vec![
281                Span::styled(prefix, style),
282                Span::styled(&line.content, style),
283            ]);
284            
285            lines.push(formatted_line);
286        }
287        
288        let paragraph = Paragraph::new(lines)
289            .style(Style::default().bg(self.theme.background()));
290        
291        renderer.render_widget(paragraph, area);
292    }
293    
294    /// Render side-by-side diff view
295    fn render_side_by_side(&self, renderer: &Renderer, area: Rect, diff: &DiffContent) {
296        // Split area into left and right halves
297        let chunks = ratatui::layout::Layout::default()
298            .direction(ratatui::layout::Direction::Horizontal)
299            .constraints([
300                ratatui::layout::Constraint::Percentage(50),
301                ratatui::layout::Constraint::Percentage(50),
302            ])
303            .split(area);
304        
305        // Render original file (left side)
306        self.render_side(renderer, chunks[0], diff, true);
307        
308        // Render modified file (right side)
309        self.render_side(renderer, chunks[1], diff, false);
310    }
311    
312    /// Render one side of the side-by-side view
313    fn render_side(&self, renderer: &Renderer, area: Rect, diff: &DiffContent, is_original: bool) {
314        let title = if is_original {
315            &diff.original_file
316        } else {
317            &diff.modified_file
318        };
319        
320        let block = Block::default()
321            .title(title)
322            .borders(Borders::ALL)
323            .border_style(Style::default().fg(self.theme.border()));
324        
325        renderer.render_widget(block.clone(), area);
326        
327        let inner_area = block.inner(area);
328        
329        let mut lines = Vec::new();
330        
331        // Collect relevant lines for this side
332        let mut all_lines = Vec::new();
333        for hunk in &diff.hunks {
334            for line in &hunk.lines {
335                match (&line.line_type, is_original) {
336                    (DiffLineType::Context, _) => all_lines.push(line),
337                    (DiffLineType::Added, false) => all_lines.push(line),
338                    (DiffLineType::Removed, true) => all_lines.push(line),
339                    _ => {
340                        // Add empty line to maintain alignment
341                        if !is_original && line.line_type == DiffLineType::Removed {
342                            // Skip removed lines in modified view
343                        } else if is_original && line.line_type == DiffLineType::Added {
344                            // Skip added lines in original view
345                        }
346                    }
347                }
348            }
349        }
350        
351        // Show visible portion
352        let visible_height = inner_area.height as usize;
353        let start_line = self.scroll_offset;
354        let end_line = (start_line + visible_height).min(all_lines.len());
355        let visible_lines = &all_lines[start_line..end_line];
356        
357        for line in visible_lines {
358            let style = match line.line_type {
359                DiffLineType::Added => Style::default().fg(self.theme.diff_added()),
360                DiffLineType::Removed => Style::default().fg(self.theme.diff_removed()),
361                DiffLineType::Context => Style::default().fg(self.theme.text()),
362                DiffLineType::Header => Style::default().fg(self.theme.accent()),
363            };
364            
365            let line_number = if is_original {
366                line.original_line_number
367            } else {
368                line.modified_line_number
369            };
370            
371            let formatted_line = if let Some(num) = line_number {
372                Line::from(vec![
373                    Span::styled(format!("{:4} ", num), Style::default().fg(self.theme.text_muted())),
374                    Span::styled(&line.content, style),
375                ])
376            } else {
377                Line::from(Span::styled(&line.content, style))
378            };
379            
380            lines.push(formatted_line);
381        }
382        
383        let paragraph = Paragraph::new(lines)
384            .style(Style::default().bg(self.theme.background()));
385        
386        renderer.render_widget(paragraph, inner_area);
387    }
388}
389
390#[cfg(test)]
391mod tests {
392    use super::*;
393    
394    #[test]
395    fn test_diff_parsing() {
396        let diff_text = r#"--- a/file.txt
397+++ b/file.txt
398@@ -1,3 +1,4 @@
399 line 1
400-line 2
401+line 2 modified
402+line 2.5 added
403 line 3"#;
404        
405        let theme = crate::theme::DefaultTheme;
406        let mut viewer = DiffViewer::new(&theme);
407        let result = viewer.load_diff(diff_text);
408        assert!(result.is_ok());
409        
410        let diff = viewer.current_diff.unwrap();
411        assert_eq!(diff.original_file, "a/file.txt");
412        assert_eq!(diff.modified_file, "b/file.txt");
413        assert_eq!(diff.hunks.len(), 1);
414        assert_eq!(diff.hunks[0].lines.len(), 4);
415    }
416    
417    #[test]
418    fn test_range_parsing() {
419        let theme = crate::theme::DefaultTheme;
420        let viewer = DiffViewer::new(&theme);
421        
422        assert_eq!(viewer.parse_range("-1,3").unwrap(), (1, 3));
423        assert_eq!(viewer.parse_range("+5,2").unwrap(), (5, 2));
424        assert_eq!(viewer.parse_range("-10").unwrap(), (10, 1));
425    }
426}