ricecoder_tui/
diff.rs

1//! Code diffing widget
2//!
3//! This module provides the `DiffWidget` for displaying and managing code changes.
4//! It supports multiple diff formats (unified and side-by-side), syntax highlighting,
5//! and hunk-level approval/rejection.
6//!
7//! # Features
8//!
9//! - **Multiple view formats**: Unified and side-by-side diff views
10//! - **Syntax highlighting**: Language-aware code highlighting
11//! - **Hunk navigation**: Jump between hunks with keyboard shortcuts
12//! - **Approval workflow**: Accept or reject individual hunks
13//! - **Line numbers**: Display original and new line numbers
14//!
15//! # Examples
16//!
17//! Creating a diff widget:
18//!
19//! ```ignore
20//! use ricecoder_tui::{DiffWidget, DiffHunk, DiffLine, DiffLineType};
21//!
22//! let mut diff = DiffWidget::new();
23//! let line = DiffLine {
24//!     line_type: DiffLineType::Added,
25//!     old_line_num: None,
26//!     new_line_num: Some(1),
27//!     content: "fn hello() {}".to_string(),
28//! };
29//! ```
30//!
31//! Navigating hunks:
32//!
33//! ```ignore
34//! diff.next_hunk();  // Move to next hunk
35//! diff.prev_hunk();  // Move to previous hunk
36//! ```
37//!
38//! Approving changes:
39//!
40//! ```ignore
41//! diff.approve_hunk(0);  // Approve first hunk
42//! diff.reject_hunk(1);   // Reject second hunk
43//! ```
44
45/// Diff line type
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum DiffLineType {
48    /// Unchanged line
49    Unchanged,
50    /// Added line
51    Added,
52    /// Removed line
53    Removed,
54    /// Context line
55    Context,
56}
57
58/// Diff line
59#[derive(Debug, Clone)]
60pub struct DiffLine {
61    /// Line type
62    pub line_type: DiffLineType,
63    /// Line number in original
64    pub old_line_num: Option<usize>,
65    /// Line number in new
66    pub new_line_num: Option<usize>,
67    /// Line content
68    pub content: String,
69}
70
71impl DiffLine {
72    /// Create a new diff line
73    pub fn new(line_type: DiffLineType, content: impl Into<String>) -> Self {
74        Self {
75            line_type,
76            old_line_num: None,
77            new_line_num: None,
78            content: content.into(),
79        }
80    }
81
82    /// Set old line number
83    pub fn with_old_line_num(mut self, num: usize) -> Self {
84        self.old_line_num = Some(num);
85        self
86    }
87
88    /// Set new line number
89    pub fn with_new_line_num(mut self, num: usize) -> Self {
90        self.new_line_num = Some(num);
91        self
92    }
93}
94
95/// Diff hunk
96#[derive(Debug, Clone)]
97pub struct DiffHunk {
98    /// Hunk header
99    pub header: String,
100    /// Lines in hunk
101    pub lines: Vec<DiffLine>,
102    /// Whether hunk is collapsed
103    pub collapsed: bool,
104}
105
106impl DiffHunk {
107    /// Create a new diff hunk
108    pub fn new(header: impl Into<String>) -> Self {
109        Self {
110            header: header.into(),
111            lines: Vec::new(),
112            collapsed: false,
113        }
114    }
115
116    /// Add a line to the hunk
117    pub fn add_line(&mut self, line: DiffLine) {
118        self.lines.push(line);
119    }
120
121    /// Toggle collapsed state
122    pub fn toggle_collapsed(&mut self) {
123        self.collapsed = !self.collapsed;
124    }
125
126    /// Get visible lines
127    pub fn visible_lines(&self) -> Vec<&DiffLine> {
128        if self.collapsed {
129            Vec::new()
130        } else {
131            self.lines.iter().collect()
132        }
133    }
134}
135
136/// Diff view type
137#[derive(Debug, Clone, Copy, PartialEq, Eq)]
138pub enum DiffViewType {
139    /// Unified diff view
140    Unified,
141    /// Side-by-side diff view
142    SideBySide,
143}
144
145/// Diff widget
146#[derive(Debug)]
147pub struct DiffWidget {
148    /// Hunks in the diff
149    pub hunks: Vec<DiffHunk>,
150    /// Current view type
151    pub view_type: DiffViewType,
152    /// Selected hunk index
153    pub selected_hunk: Option<usize>,
154    /// Scroll offset
155    pub scroll: usize,
156    /// Approval state for each hunk
157    pub approvals: Vec<bool>,
158}
159
160impl DiffWidget {
161    /// Create a new diff widget
162    pub fn new() -> Self {
163        Self {
164            hunks: Vec::new(),
165            view_type: DiffViewType::Unified,
166            selected_hunk: None,
167            scroll: 0,
168            approvals: Vec::new(),
169        }
170    }
171
172    /// Add a hunk
173    pub fn add_hunk(&mut self, hunk: DiffHunk) {
174        self.hunks.push(hunk);
175        self.approvals.push(false);
176    }
177
178    /// Toggle view type
179    pub fn toggle_view_type(&mut self) {
180        self.view_type = match self.view_type {
181            DiffViewType::Unified => DiffViewType::SideBySide,
182            DiffViewType::SideBySide => DiffViewType::Unified,
183        };
184    }
185
186    /// Select next hunk
187    pub fn select_next_hunk(&mut self) {
188        if self.hunks.is_empty() {
189            return;
190        }
191        match self.selected_hunk {
192            None => self.selected_hunk = Some(0),
193            Some(idx) if idx < self.hunks.len() - 1 => self.selected_hunk = Some(idx + 1),
194            _ => {}
195        }
196    }
197
198    /// Select previous hunk
199    pub fn select_prev_hunk(&mut self) {
200        match self.selected_hunk {
201            None => {}
202            Some(0) => self.selected_hunk = None,
203            Some(idx) => self.selected_hunk = Some(idx - 1),
204        }
205    }
206
207    /// Toggle selected hunk collapsed state
208    pub fn toggle_selected_hunk(&mut self) {
209        if let Some(idx) = self.selected_hunk {
210            if let Some(hunk) = self.hunks.get_mut(idx) {
211                hunk.toggle_collapsed();
212            }
213        }
214    }
215
216    /// Approve all changes
217    pub fn approve_all(&mut self) {
218        for approval in &mut self.approvals {
219            *approval = true;
220        }
221    }
222
223    /// Reject all changes
224    pub fn reject_all(&mut self) {
225        for approval in &mut self.approvals {
226            *approval = false;
227        }
228    }
229
230    /// Approve selected hunk
231    pub fn approve_hunk(&mut self) {
232        if let Some(idx) = self.selected_hunk {
233            if let Some(approval) = self.approvals.get_mut(idx) {
234                *approval = true;
235            }
236        }
237    }
238
239    /// Reject selected hunk
240    pub fn reject_hunk(&mut self) {
241        if let Some(idx) = self.selected_hunk {
242            if let Some(approval) = self.approvals.get_mut(idx) {
243                *approval = false;
244            }
245        }
246    }
247
248    /// Get all approved hunks
249    pub fn approved_hunks(&self) -> Vec<usize> {
250        self.approvals
251            .iter()
252            .enumerate()
253            .filter_map(|(idx, &approved)| if approved { Some(idx) } else { None })
254            .collect()
255    }
256
257    /// Get all rejected hunks
258    pub fn rejected_hunks(&self) -> Vec<usize> {
259        self.approvals
260            .iter()
261            .enumerate()
262            .filter_map(|(idx, &approved)| if !approved { Some(idx) } else { None })
263            .collect()
264    }
265
266    /// Scroll up
267    pub fn scroll_up(&mut self) {
268        if self.scroll > 0 {
269            self.scroll -= 1;
270        }
271    }
272
273    /// Scroll down
274    pub fn scroll_down(&mut self, height: usize) {
275        let total_lines: usize = self.hunks.iter().map(|h| h.visible_lines().len()).sum();
276        let max_scroll = total_lines.saturating_sub(height);
277        if self.scroll < max_scroll {
278            self.scroll += 1;
279        }
280    }
281}
282
283impl Default for DiffWidget {
284    fn default() -> Self {
285        Self::new()
286    }
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292
293    #[test]
294    fn test_diff_line_creation() {
295        let line = DiffLine::new(DiffLineType::Added, "let x = 5;");
296        assert_eq!(line.line_type, DiffLineType::Added);
297        assert_eq!(line.content, "let x = 5;");
298    }
299
300    #[test]
301    fn test_diff_line_numbers() {
302        let line = DiffLine::new(DiffLineType::Unchanged, "code")
303            .with_old_line_num(1)
304            .with_new_line_num(1);
305
306        assert_eq!(line.old_line_num, Some(1));
307        assert_eq!(line.new_line_num, Some(1));
308    }
309
310    #[test]
311    fn test_diff_hunk_creation() {
312        let hunk = DiffHunk::new("@@ -1,5 +1,6 @@");
313        assert_eq!(hunk.header, "@@ -1,5 +1,6 @@");
314        assert!(!hunk.collapsed);
315    }
316
317    #[test]
318    fn test_diff_hunk_add_line() {
319        let mut hunk = DiffHunk::new("@@ -1,5 +1,6 @@");
320        hunk.add_line(DiffLine::new(DiffLineType::Added, "new line"));
321
322        assert_eq!(hunk.lines.len(), 1);
323    }
324
325    #[test]
326    fn test_diff_hunk_toggle_collapsed() {
327        let mut hunk = DiffHunk::new("@@ -1,5 +1,6 @@");
328        assert!(!hunk.collapsed);
329
330        hunk.toggle_collapsed();
331        assert!(hunk.collapsed);
332
333        hunk.toggle_collapsed();
334        assert!(!hunk.collapsed);
335    }
336
337    #[test]
338    fn test_diff_hunk_visible_lines() {
339        let mut hunk = DiffHunk::new("@@ -1,5 +1,6 @@");
340        hunk.add_line(DiffLine::new(DiffLineType::Added, "line 1"));
341        hunk.add_line(DiffLine::new(DiffLineType::Removed, "line 2"));
342
343        assert_eq!(hunk.visible_lines().len(), 2);
344
345        hunk.toggle_collapsed();
346        assert_eq!(hunk.visible_lines().len(), 0);
347    }
348
349    #[test]
350    fn test_diff_widget_creation() {
351        let widget = DiffWidget::new();
352        assert!(widget.hunks.is_empty());
353        assert_eq!(widget.view_type, DiffViewType::Unified);
354    }
355
356    #[test]
357    fn test_diff_widget_add_hunk() {
358        let mut widget = DiffWidget::new();
359        let hunk = DiffHunk::new("@@ -1,5 +1,6 @@");
360        widget.add_hunk(hunk);
361
362        assert_eq!(widget.hunks.len(), 1);
363        assert_eq!(widget.approvals.len(), 1);
364    }
365
366    #[test]
367    fn test_diff_widget_toggle_view_type() {
368        let mut widget = DiffWidget::new();
369        assert_eq!(widget.view_type, DiffViewType::Unified);
370
371        widget.toggle_view_type();
372        assert_eq!(widget.view_type, DiffViewType::SideBySide);
373
374        widget.toggle_view_type();
375        assert_eq!(widget.view_type, DiffViewType::Unified);
376    }
377
378    #[test]
379    fn test_diff_widget_hunk_selection() {
380        let mut widget = DiffWidget::new();
381        widget.add_hunk(DiffHunk::new("@@ -1,5 +1,6 @@"));
382        widget.add_hunk(DiffHunk::new("@@ -10,5 +11,6 @@"));
383
384        widget.select_next_hunk();
385        assert_eq!(widget.selected_hunk, Some(0));
386
387        widget.select_next_hunk();
388        assert_eq!(widget.selected_hunk, Some(1));
389
390        widget.select_prev_hunk();
391        assert_eq!(widget.selected_hunk, Some(0));
392    }
393
394    #[test]
395    fn test_diff_widget_approval() {
396        let mut widget = DiffWidget::new();
397        widget.add_hunk(DiffHunk::new("@@ -1,5 +1,6 @@"));
398        widget.add_hunk(DiffHunk::new("@@ -10,5 +11,6 @@"));
399
400        widget.approve_all();
401        assert_eq!(widget.approved_hunks().len(), 2);
402        assert_eq!(widget.rejected_hunks().len(), 0);
403
404        widget.reject_all();
405        assert_eq!(widget.approved_hunks().len(), 0);
406        assert_eq!(widget.rejected_hunks().len(), 2);
407    }
408
409    #[test]
410    fn test_diff_widget_hunk_approval() {
411        let mut widget = DiffWidget::new();
412        widget.add_hunk(DiffHunk::new("@@ -1,5 +1,6 @@"));
413        widget.add_hunk(DiffHunk::new("@@ -10,5 +11,6 @@"));
414
415        widget.select_next_hunk();
416        widget.approve_hunk();
417
418        assert_eq!(widget.approved_hunks().len(), 1);
419        assert_eq!(widget.approved_hunks()[0], 0);
420    }
421
422    #[test]
423    fn test_diff_widget_scroll() {
424        let mut widget = DiffWidget::new();
425        let mut hunk = DiffHunk::new("@@ -1,5 +1,6 @@");
426        for i in 0..20 {
427            hunk.add_line(DiffLine::new(
428                DiffLineType::Unchanged,
429                format!("line {}", i),
430            ));
431        }
432        widget.add_hunk(hunk);
433
434        assert_eq!(widget.scroll, 0);
435
436        widget.scroll_down(10);
437        assert_eq!(widget.scroll, 1);
438
439        widget.scroll_up();
440        assert_eq!(widget.scroll, 0);
441
442        widget.scroll_up();
443        assert_eq!(widget.scroll, 0);
444    }
445}