Skip to main content

git_iris/studio/components/
diff_view.rs

1//! Diff view component for Iris Studio
2//!
3//! Displays git diffs with syntax highlighting for added/removed lines.
4
5use crate::studio::theme;
6use crate::studio::utils::{expand_tabs, truncate_width};
7use ratatui::Frame;
8use ratatui::layout::Rect;
9use ratatui::style::{Modifier, Style};
10use ratatui::text::{Line, Span};
11use ratatui::widgets::{
12    Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState,
13};
14use std::path::PathBuf;
15
16// ═══════════════════════════════════════════════════════════════════════════════
17// Diff Types
18// ═══════════════════════════════════════════════════════════════════════════════
19
20/// Type of diff line
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum DiffLineType {
23    /// Context line (unchanged)
24    Context,
25    /// Added line
26    Added,
27    /// Removed line
28    Removed,
29    /// Hunk header (@@ ... @@)
30    HunkHeader,
31    /// File header (--- or +++)
32    FileHeader,
33    /// Empty/separator
34    Empty,
35}
36
37impl DiffLineType {
38    /// Get style for this line type
39    #[must_use]
40    pub fn style(self) -> Style {
41        match self {
42            Self::Context => theme::diff_context(),
43            Self::Added => theme::diff_added(),
44            Self::Removed => theme::diff_removed(),
45            Self::HunkHeader => theme::diff_hunk(),
46            Self::FileHeader => Style::default()
47                .fg(theme::text_primary_color())
48                .add_modifier(Modifier::BOLD),
49            Self::Empty => Style::default(),
50        }
51    }
52
53    /// Get the line prefix character
54    #[must_use]
55    pub fn prefix(self) -> &'static str {
56        match self {
57            Self::Context => " ",
58            Self::Added => "+",
59            Self::Removed => "-",
60            Self::HunkHeader => "@",
61            Self::FileHeader => "",
62            Self::Empty => " ",
63        }
64    }
65}
66
67// ═══════════════════════════════════════════════════════════════════════════════
68// Diff Line
69// ═══════════════════════════════════════════════════════════════════════════════
70
71/// A single line in a diff
72#[derive(Debug, Clone)]
73pub struct DiffLine {
74    /// Type of line
75    pub line_type: DiffLineType,
76    /// Line content (without prefix)
77    pub content: String,
78    /// Old line number (for context and removed)
79    pub old_line_num: Option<usize>,
80    /// New line number (for context and added)
81    pub new_line_num: Option<usize>,
82}
83
84impl DiffLine {
85    /// Create a context line
86    pub fn context(content: impl Into<String>, old_num: usize, new_num: usize) -> Self {
87        Self {
88            line_type: DiffLineType::Context,
89            content: content.into(),
90            old_line_num: Some(old_num),
91            new_line_num: Some(new_num),
92        }
93    }
94
95    /// Create an added line
96    pub fn added(content: impl Into<String>, new_num: usize) -> Self {
97        Self {
98            line_type: DiffLineType::Added,
99            content: content.into(),
100            old_line_num: None,
101            new_line_num: Some(new_num),
102        }
103    }
104
105    /// Create a removed line
106    pub fn removed(content: impl Into<String>, old_num: usize) -> Self {
107        Self {
108            line_type: DiffLineType::Removed,
109            content: content.into(),
110            old_line_num: Some(old_num),
111            new_line_num: None,
112        }
113    }
114
115    /// Create a hunk header line
116    pub fn hunk_header(content: impl Into<String>) -> Self {
117        Self {
118            line_type: DiffLineType::HunkHeader,
119            content: content.into(),
120            old_line_num: None,
121            new_line_num: None,
122        }
123    }
124
125    /// Create a file header line
126    pub fn file_header(content: impl Into<String>) -> Self {
127        Self {
128            line_type: DiffLineType::FileHeader,
129            content: content.into(),
130            old_line_num: None,
131            new_line_num: None,
132        }
133    }
134}
135
136// ═══════════════════════════════════════════════════════════════════════════════
137// Diff Hunk
138// ═══════════════════════════════════════════════════════════════════════════════
139
140/// A single hunk in a diff
141#[derive(Debug, Clone)]
142pub struct DiffHunk {
143    /// Header line (@@...@@)
144    pub header: String,
145    /// Lines in this hunk
146    pub lines: Vec<DiffLine>,
147    /// Starting line in old file
148    pub old_start: usize,
149    /// Number of lines in old file
150    pub old_count: usize,
151    /// Starting line in new file
152    pub new_start: usize,
153    /// Number of lines in new file
154    pub new_count: usize,
155}
156
157// ═══════════════════════════════════════════════════════════════════════════════
158// File Diff
159// ═══════════════════════════════════════════════════════════════════════════════
160
161/// Diff for a single file
162#[derive(Debug, Clone)]
163pub struct FileDiff {
164    /// File path
165    pub path: PathBuf,
166    /// Old path (for renames)
167    pub old_path: Option<PathBuf>,
168    /// Is this a new file?
169    pub is_new: bool,
170    /// Is this a deleted file?
171    pub is_deleted: bool,
172    /// Is this a binary file?
173    pub is_binary: bool,
174    /// Hunks in this diff
175    pub hunks: Vec<DiffHunk>,
176}
177
178impl FileDiff {
179    /// Create a new file diff
180    pub fn new(path: impl Into<PathBuf>) -> Self {
181        Self {
182            path: path.into(),
183            old_path: None,
184            is_new: false,
185            is_deleted: false,
186            is_binary: false,
187            hunks: Vec::new(),
188        }
189    }
190
191    /// Get total lines changed (added + removed)
192    #[must_use]
193    pub fn lines_changed(&self) -> (usize, usize) {
194        let mut added = 0;
195        let mut removed = 0;
196        for hunk in &self.hunks {
197            for line in &hunk.lines {
198                match line.line_type {
199                    DiffLineType::Added => added += 1,
200                    DiffLineType::Removed => removed += 1,
201                    _ => {}
202                }
203            }
204        }
205        (added, removed)
206    }
207
208    /// Get all lines for display
209    #[must_use]
210    pub fn all_lines(&self) -> Vec<DiffLine> {
211        let mut lines = Vec::new();
212
213        // File header
214        let status = if self.is_new {
215            " (new)"
216        } else if self.is_deleted {
217            " (deleted)"
218        } else {
219            ""
220        };
221        lines.push(DiffLine::file_header(format!(
222            "{}{}",
223            self.path.display(),
224            status
225        )));
226
227        if self.is_binary {
228            lines.push(DiffLine {
229                line_type: DiffLineType::Empty,
230                content: "Binary file".to_string(),
231                old_line_num: None,
232                new_line_num: None,
233            });
234            return lines;
235        }
236
237        for hunk in &self.hunks {
238            lines.push(DiffLine::hunk_header(&hunk.header));
239            lines.extend(hunk.lines.clone());
240        }
241
242        lines
243    }
244}
245
246// ═══════════════════════════════════════════════════════════════════════════════
247// Diff View State
248// ═══════════════════════════════════════════════════════════════════════════════
249
250/// State for the diff view widget
251#[derive(Debug, Clone)]
252pub struct DiffViewState {
253    /// All file diffs
254    diffs: Vec<FileDiff>,
255    /// Currently selected file index
256    selected_file: usize,
257    /// Scroll offset (line)
258    scroll_offset: usize,
259    /// Selected line index within current file
260    selected_line: usize,
261    /// Cached all lines for current file
262    cached_lines: Vec<DiffLine>,
263}
264
265impl Default for DiffViewState {
266    fn default() -> Self {
267        Self::new()
268    }
269}
270
271impl DiffViewState {
272    /// Create new diff view state
273    #[must_use]
274    pub fn new() -> Self {
275        Self {
276            diffs: Vec::new(),
277            selected_file: 0,
278            scroll_offset: 0,
279            selected_line: 0,
280            cached_lines: Vec::new(),
281        }
282    }
283
284    /// Set diffs to display
285    pub fn set_diffs(&mut self, diffs: Vec<FileDiff>) {
286        self.diffs = diffs;
287        self.selected_file = 0;
288        self.scroll_offset = 0;
289        self.selected_line = 0;
290        self.update_cache();
291    }
292
293    /// Update cached lines
294    fn update_cache(&mut self) {
295        self.cached_lines = if let Some(diff) = self.diffs.get(self.selected_file) {
296            diff.all_lines()
297        } else {
298            Vec::new()
299        };
300    }
301
302    /// Get current file diff
303    #[must_use]
304    pub fn current_diff(&self) -> Option<&FileDiff> {
305        self.diffs.get(self.selected_file)
306    }
307
308    /// Get number of files
309    #[must_use]
310    pub fn file_count(&self) -> usize {
311        self.diffs.len()
312    }
313
314    /// Select next file
315    pub fn next_file(&mut self) {
316        if self.selected_file + 1 < self.diffs.len() {
317            self.selected_file += 1;
318            self.scroll_offset = 0;
319            self.selected_line = 0;
320            self.update_cache();
321        }
322    }
323
324    /// Select previous file
325    pub fn prev_file(&mut self) {
326        if self.selected_file > 0 {
327            self.selected_file -= 1;
328            self.scroll_offset = 0;
329            self.selected_line = 0;
330            self.update_cache();
331        }
332    }
333
334    /// Select file by index
335    pub fn select_file(&mut self, index: usize) {
336        if index < self.diffs.len() {
337            self.selected_file = index;
338            self.scroll_offset = 0;
339            self.selected_line = 0;
340            self.update_cache();
341        }
342    }
343
344    /// Scroll up
345    pub fn scroll_up(&mut self, amount: usize) {
346        self.scroll_offset = self.scroll_offset.saturating_sub(amount);
347    }
348
349    /// Scroll down
350    pub fn scroll_down(&mut self, amount: usize) {
351        let max_offset = self.cached_lines.len().saturating_sub(1);
352        self.scroll_offset = (self.scroll_offset + amount).min(max_offset);
353    }
354
355    /// Scroll to top
356    pub fn scroll_to_top(&mut self) {
357        self.scroll_offset = 0;
358    }
359
360    /// Scroll to bottom
361    pub fn scroll_to_bottom(&mut self) {
362        self.scroll_offset = self.cached_lines.len().saturating_sub(1);
363    }
364
365    /// Go to next hunk
366    pub fn next_hunk(&mut self) {
367        let lines = &self.cached_lines;
368        for (i, line) in lines.iter().enumerate().skip(self.scroll_offset + 1) {
369            if line.line_type == DiffLineType::HunkHeader {
370                self.scroll_offset = i;
371                return;
372            }
373        }
374    }
375
376    /// Go to previous hunk
377    pub fn prev_hunk(&mut self) {
378        let lines = &self.cached_lines;
379        for i in (0..self.scroll_offset).rev() {
380            if lines
381                .get(i)
382                .is_some_and(|l| l.line_type == DiffLineType::HunkHeader)
383            {
384                self.scroll_offset = i;
385                return;
386            }
387        }
388    }
389
390    /// Get cached lines
391    #[must_use]
392    pub fn lines(&self) -> &[DiffLine] {
393        &self.cached_lines
394    }
395
396    /// Get scroll offset
397    #[must_use]
398    pub fn scroll_offset(&self) -> usize {
399        self.scroll_offset
400    }
401
402    /// Get selected file index
403    #[must_use]
404    pub fn selected_file_index(&self) -> usize {
405        self.selected_file
406    }
407
408    /// Select file by path (returns true if found)
409    pub fn select_file_by_path(&mut self, path: &std::path::Path) -> bool {
410        for (i, diff) in self.diffs.iter().enumerate() {
411            if diff.path == path {
412                self.select_file(i);
413                return true;
414            }
415        }
416        false
417    }
418
419    /// Get all file paths in the diff
420    #[must_use]
421    pub fn file_paths(&self) -> Vec<&std::path::Path> {
422        self.diffs.iter().map(|d| d.path.as_path()).collect()
423    }
424}
425
426// ═══════════════════════════════════════════════════════════════════════════════
427// Parsing
428// ═══════════════════════════════════════════════════════════════════════════════
429
430/// Parse a unified diff string into `FileDiff` structs
431#[must_use]
432pub fn parse_diff(diff_text: &str) -> Vec<FileDiff> {
433    let mut diffs = Vec::new();
434    let mut current_diff: Option<FileDiff> = None;
435    let mut current_hunk: Option<DiffHunk> = None;
436    let mut old_line = 0;
437    let mut new_line = 0;
438
439    for line in diff_text.lines() {
440        if line.starts_with("diff --git") {
441            // Save previous diff
442            if let Some(mut diff) = current_diff.take() {
443                if let Some(hunk) = current_hunk.take() {
444                    diff.hunks.push(hunk);
445                }
446                diffs.push(diff);
447            }
448
449            // Parse file path from "diff --git a/path b/path"
450            let parts: Vec<&str> = line.split(' ').collect();
451            if parts.len() >= 4 {
452                let path = parts[3].strip_prefix("b/").unwrap_or(parts[3]);
453                current_diff = Some(FileDiff::new(path));
454            }
455        } else if line.starts_with("new file") {
456            if let Some(ref mut diff) = current_diff {
457                diff.is_new = true;
458            }
459        } else if line.starts_with("deleted file") {
460            if let Some(ref mut diff) = current_diff {
461                diff.is_deleted = true;
462            }
463        } else if line.starts_with("Binary files") {
464            if let Some(ref mut diff) = current_diff {
465                diff.is_binary = true;
466            }
467        } else if line.starts_with("@@") {
468            // Save previous hunk
469            if let Some(ref mut diff) = current_diff
470                && let Some(hunk) = current_hunk.take()
471            {
472                diff.hunks.push(hunk);
473            }
474
475            // Parse hunk header: @@ -old_start,old_count +new_start,new_count @@
476            let mut hunk = DiffHunk {
477                header: line.to_string(),
478                lines: Vec::new(),
479                old_start: 0,
480                old_count: 0,
481                new_start: 0,
482                new_count: 0,
483            };
484
485            // Simple parsing of line numbers
486            if let Some(at_section) = line.strip_prefix("@@ ")
487                && let Some(end) = at_section.find(" @@")
488            {
489                let range_part = &at_section[..end];
490                let parts: Vec<&str> = range_part.split(' ').collect();
491
492                for part in parts {
493                    if let Some(old) = part.strip_prefix('-') {
494                        let nums: Vec<&str> = old.split(',').collect();
495                        hunk.old_start = nums.first().and_then(|s| s.parse().ok()).unwrap_or(0);
496                        hunk.old_count = nums.get(1).and_then(|s| s.parse().ok()).unwrap_or(1);
497                    } else if let Some(new) = part.strip_prefix('+') {
498                        let nums: Vec<&str> = new.split(',').collect();
499                        hunk.new_start = nums.first().and_then(|s| s.parse().ok()).unwrap_or(0);
500                        hunk.new_count = nums.get(1).and_then(|s| s.parse().ok()).unwrap_or(1);
501                    }
502                }
503            }
504
505            old_line = hunk.old_start;
506            new_line = hunk.new_start;
507            current_hunk = Some(hunk);
508        } else if let Some(ref mut hunk) = current_hunk {
509            let diff_line = if let Some(content) = line.strip_prefix('+') {
510                let dl = DiffLine::added(content, new_line);
511                new_line += 1;
512                dl
513            } else if let Some(content) = line.strip_prefix('-') {
514                let dl = DiffLine::removed(content, old_line);
515                old_line += 1;
516                dl
517            } else if let Some(content) = line.strip_prefix(' ') {
518                let dl = DiffLine::context(content, old_line, new_line);
519                old_line += 1;
520                new_line += 1;
521                dl
522            } else {
523                // Treat as context (handles lines without prefix)
524                let dl = DiffLine::context(line, old_line, new_line);
525                old_line += 1;
526                new_line += 1;
527                dl
528            };
529            hunk.lines.push(diff_line);
530        }
531    }
532
533    // Save final diff/hunk
534    if let Some(mut diff) = current_diff {
535        if let Some(hunk) = current_hunk {
536            diff.hunks.push(hunk);
537        }
538        diffs.push(diff);
539    }
540
541    diffs
542}
543
544// ═══════════════════════════════════════════════════════════════════════════════
545// Rendering
546// ═══════════════════════════════════════════════════════════════════════════════
547
548/// Render the diff view widget
549pub fn render_diff_view(
550    frame: &mut Frame,
551    area: Rect,
552    state: &DiffViewState,
553    title: &str,
554    focused: bool,
555) {
556    let block = Block::default()
557        .title(format!(" {} ", title))
558        .borders(Borders::ALL)
559        .border_style(if focused {
560            theme::focused_border()
561        } else {
562            theme::unfocused_border()
563        });
564
565    let inner = block.inner(area);
566    frame.render_widget(block, area);
567
568    if inner.height == 0 || inner.width == 0 {
569        return;
570    }
571
572    let visible_height = inner.height as usize;
573    let lines = state.lines();
574    let scroll_offset = state.scroll_offset();
575    let line_num_width = 4; // Width for line numbers
576
577    let display_lines: Vec<Line> = lines
578        .iter()
579        .skip(scroll_offset)
580        .take(visible_height)
581        .map(|line| render_diff_line(line, line_num_width, inner.width as usize))
582        .collect();
583
584    let paragraph = Paragraph::new(display_lines);
585    frame.render_widget(paragraph, inner);
586
587    // Render scrollbar if needed
588    if lines.len() > visible_height {
589        let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
590            .begin_symbol(None)
591            .end_symbol(None);
592
593        let mut scrollbar_state = ScrollbarState::new(lines.len()).position(scroll_offset);
594
595        frame.render_stateful_widget(
596            scrollbar,
597            area.inner(ratatui::layout::Margin {
598                vertical: 1,
599                horizontal: 0,
600            }),
601            &mut scrollbar_state,
602        );
603    }
604}
605
606/// Render a single diff line
607fn render_diff_line(line: &DiffLine, line_num_width: usize, width: usize) -> Line<'static> {
608    let style = line.line_type.style();
609
610    match line.line_type {
611        DiffLineType::FileHeader => {
612            let expanded = expand_tabs(&line.content, 4);
613            let content = format!("━━━ {} ", expanded);
614            let truncated = truncate_width(&content, width);
615            Line::from(vec![Span::styled(truncated, style)])
616        }
617        DiffLineType::HunkHeader => {
618            // "     " prefix takes line_num_width * 2 + 3
619            let expanded = expand_tabs(&line.content, 4);
620            let prefix_width = line_num_width * 2 + 4;
621            let max_content = width.saturating_sub(prefix_width);
622            let truncated = truncate_width(&expanded, max_content);
623            Line::from(vec![
624                Span::styled(
625                    format!("{:>width$} ", "", width = line_num_width * 2 + 3),
626                    Style::default(),
627                ),
628                Span::styled(truncated, style),
629            ])
630        }
631        DiffLineType::Added | DiffLineType::Removed | DiffLineType::Context => {
632            let old_num = line.old_line_num.map_or_else(
633                || " ".repeat(line_num_width),
634                |n| format!("{:>width$}", n, width = line_num_width),
635            );
636
637            let new_num = line.new_line_num.map_or_else(
638                || " ".repeat(line_num_width),
639                |n| format!("{:>width$}", n, width = line_num_width),
640            );
641
642            let prefix = line.line_type.prefix();
643            let prefix_style = match line.line_type {
644                DiffLineType::Added => Style::default()
645                    .fg(theme::success_color())
646                    .add_modifier(Modifier::BOLD),
647                DiffLineType::Removed => Style::default()
648                    .fg(theme::error_color())
649                    .add_modifier(Modifier::BOLD),
650                _ => theme::dimmed(),
651            };
652
653            // Expand tabs to spaces for proper width calculation and rendering
654            let expanded_content = expand_tabs(&line.content, 4);
655
656            // Calculate available width for content
657            // Format: "XXXX │ XXXX +content"
658            let fixed_width = line_num_width * 2 + 6; // " │ " (3) + " " (1) + prefix (1) + padding (1)
659            let max_content = width.saturating_sub(fixed_width);
660            let truncated = truncate_width(&expanded_content, max_content);
661
662            Line::from(vec![
663                Span::styled(old_num, theme::dimmed()),
664                Span::styled(" │ ", theme::dimmed()),
665                Span::styled(new_num, theme::dimmed()),
666                Span::raw(" "),
667                Span::styled(prefix, prefix_style),
668                Span::styled(truncated, style),
669            ])
670        }
671        DiffLineType::Empty => Line::from(""),
672    }
673}
674
675/// Render a compact summary of changes
676#[must_use]
677pub fn render_diff_summary(diff: &FileDiff) -> Line<'static> {
678    let (added, removed) = diff.lines_changed();
679    let path = diff.path.display().to_string();
680
681    let status = if diff.is_new {
682        Span::styled(" new ", Style::default().fg(theme::success_color()))
683    } else if diff.is_deleted {
684        Span::styled(" del ", Style::default().fg(theme::error_color()))
685    } else {
686        Span::raw("")
687    };
688
689    Line::from(vec![
690        Span::styled(path, theme::file_path()),
691        status,
692        Span::styled(
693            format!("+{added}"),
694            Style::default().fg(theme::success_color()),
695        ),
696        Span::raw(" "),
697        Span::styled(
698            format!("-{removed}"),
699            Style::default().fg(theme::error_color()),
700        ),
701    ])
702}