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