Skip to main content

gitkraft_tui/features/diff/
view.rs

1use ratatui::layout::{Constraint, Direction, Layout, Rect};
2use ratatui::style::{Modifier, Style};
3use ratatui::text::{Line, Span};
4use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph, Wrap};
5use ratatui::Frame;
6
7use gitkraft_core::DiffLine;
8
9use crate::app::{ActivePane, App, DiffSubPane};
10
11/// Render the diff pane — shows colored hunks when a diff is selected,
12/// or a placeholder message when nothing is selected.
13///
14/// When the current commit has more than one changed file, the area is
15/// split horizontally into a file-list sidebar on the left and the diff
16/// content on the right, matching the GUI's "Files" panel.
17pub fn render(app: &mut App, frame: &mut Frame, area: Rect) {
18    // ── Commit range diff takes priority ──────────────────────────────────
19    if !app.tab().commit_range_diffs.is_empty() {
20        render_commit_range_diff(app, frame, area);
21        return;
22    }
23
24    let theme = app.theme();
25    let is_active = app.active_pane == ActivePane::DiffView;
26    let sub_pane = app.tab().diff_sub_pane.clone();
27
28    let file_list_border = if is_active && sub_pane == DiffSubPane::FileList {
29        theme.border_active
30    } else {
31        theme.border_inactive
32    };
33    let content_border = if is_active && sub_pane == DiffSubPane::Content {
34        theme.border_active
35    } else {
36        theme.border_inactive
37    };
38
39    if !app.tab().commit_files.is_empty() {
40        let chunks = Layout::default()
41            .direction(Direction::Horizontal)
42            .constraints([Constraint::Length(30), Constraint::Min(20)])
43            .split(area);
44        render_file_list(app, frame, chunks[0], file_list_border);
45        render_diff_content(app, frame, chunks[1], content_border);
46    } else {
47        render_diff_content(app, frame, area, content_border);
48    }
49}
50
51/// Render the file list sidebar for the current commit's changed files.
52fn render_file_list(
53    app: &mut App,
54    frame: &mut Frame,
55    area: Rect,
56    border_color: ratatui::style::Color,
57) {
58    let theme = app.theme();
59    let tab = app.tab();
60    let commit_diff_file_index = tab.commit_diff_file_index;
61    let is_active_file_list = app.active_pane == crate::app::ActivePane::DiffView
62        && tab.diff_sub_pane == crate::app::DiffSubPane::FileList;
63
64    // Pre-sort selected indices for stable 1-based rank badges.
65    let mut sorted_selected: Vec<usize> = tab.selected_file_indices.iter().copied().collect();
66    sorted_selected.sort_unstable();
67    let multi = sorted_selected.len() >= 2;
68
69    let title = if multi {
70        format!(
71            " Files ({}) — {} selected [J/K shrink · e open all] ",
72            tab.commit_files.len(),
73            sorted_selected.len()
74        )
75    } else if is_active_file_list {
76        format!(" Files ({}) [J/K select · e open] ", tab.commit_files.len())
77    } else {
78        format!(" Files ({}) ", tab.commit_files.len())
79    };
80
81    let block = Block::default()
82        .title(title)
83        .borders(Borders::ALL)
84        .border_style(Style::default().fg(border_color));
85
86    let items: Vec<ListItem> = tab
87        .commit_files
88        .iter()
89        .enumerate()
90        .map(|(i, diff)| {
91            let is_current = i == commit_diff_file_index;
92            let is_multi = tab.selected_file_indices.contains(&i);
93            let file_name = diff.file_name();
94            let status_char = format!("{}", diff.status);
95
96            let status_color = match diff.status.color_category() {
97                gitkraft_core::StatusColorCategory::Added => theme.success,
98                gitkraft_core::StatusColorCategory::Modified => theme.warning,
99                gitkraft_core::StatusColorCategory::Deleted => theme.error,
100                gitkraft_core::StatusColorCategory::Renamed => theme.accent,
101            };
102
103            let name_style = if is_current {
104                Style::default().fg(theme.text_primary)
105            } else if is_multi {
106                Style::default()
107                    .fg(theme.accent)
108                    .add_modifier(Modifier::BOLD)
109            } else {
110                Style::default().fg(theme.text_secondary)
111            };
112
113            // Rank badge: number for range selection, ● for single toggle, blank otherwise.
114            let badge = if let Some(pos) = sorted_selected.iter().position(|&s| s == i) {
115                if multi {
116                    format!("{:<2}", pos + 1)
117                } else {
118                    "● ".to_string()
119                }
120            } else {
121                "  ".to_string()
122            };
123
124            let line = Line::from(vec![
125                Span::styled(
126                    badge,
127                    Style::default()
128                        .fg(theme.accent)
129                        .add_modifier(Modifier::BOLD),
130                ),
131                Span::styled(
132                    format!("{} ", status_char),
133                    Style::default()
134                        .fg(status_color)
135                        .add_modifier(Modifier::BOLD),
136                ),
137                Span::styled(file_name.to_string(), name_style),
138            ]);
139
140            ListItem::new(line)
141        })
142        .collect();
143
144    let mut list_state = ratatui::widgets::ListState::default();
145    list_state.select(Some(commit_diff_file_index));
146
147    let list = List::new(items)
148        .block(block)
149        .highlight_style(
150            Style::default()
151                .bg(theme.sel_bg)
152                .add_modifier(Modifier::REVERSED),
153        )
154        .highlight_symbol("▸ ");
155
156    frame.render_stateful_widget(list, area, &mut list_state);
157}
158
159/// Render the combined range diff for multiple selected commits.
160fn render_commit_range_diff(app: &mut App, frame: &mut Frame, area: Rect) {
161    let theme = app.theme();
162    let is_active = app.active_pane == ActivePane::DiffView;
163    let border_color = if is_active {
164        theme.border_active
165    } else {
166        theme.border_inactive
167    };
168
169    let count = app.tab().selected_commits.len();
170    let title = format!(" Combined diff ({} commits) ", count);
171
172    let block = Block::default()
173        .title(title)
174        .borders(Borders::ALL)
175        .border_style(Style::default().fg(border_color));
176
177    let diffs = app.tab().commit_range_diffs.clone();
178    let mut lines: Vec<Line> = Vec::new();
179
180    for diff in &diffs {
181        // File header
182        lines.push(Line::from(Span::styled(
183            format!("══ {} ══", diff.display_path()),
184            Style::default()
185                .fg(theme.diff_hunk)
186                .add_modifier(Modifier::BOLD),
187        )));
188
189        for hunk in &diff.hunks {
190            for line in &hunk.lines {
191                lines.push(styled_diff_line(line, &theme));
192            }
193        }
194        lines.push(Line::default()); // blank separator
195    }
196
197    // Clamp scroll
198    let content_height = lines.len() as u16;
199    let visible_height = area.height.saturating_sub(2);
200    {
201        let tab = app.tab_mut();
202        if content_height > visible_height {
203            if tab.diff_scroll > content_height.saturating_sub(visible_height) {
204                tab.diff_scroll = content_height.saturating_sub(visible_height);
205            }
206        } else {
207            tab.diff_scroll = 0;
208        }
209    }
210
211    let scroll = app.tab().diff_scroll;
212    let paragraph = Paragraph::new(lines)
213        .block(block)
214        .wrap(Wrap { trim: false })
215        .scroll((scroll, 0));
216
217    frame.render_widget(paragraph, area);
218}
219
220/// Convert a single `DiffLine` into a styled ratatui `Line`.
221fn styled_diff_line(
222    line: &DiffLine,
223    theme: &crate::features::theme::palette::UiTheme,
224) -> Line<'static> {
225    match line {
226        DiffLine::Addition(s) => Line::from(Span::styled(
227            format!("+{}", s),
228            Style::default().fg(theme.diff_add),
229        )),
230        DiffLine::Deletion(s) => Line::from(Span::styled(
231            format!("-{}", s),
232            Style::default().fg(theme.diff_del),
233        )),
234        DiffLine::Context(s) => Line::from(Span::styled(
235            format!(" {}", s),
236            Style::default().fg(theme.diff_context),
237        )),
238        DiffLine::HunkHeader(s) => Line::from(Span::styled(
239            s.clone(),
240            Style::default()
241                .fg(theme.diff_hunk)
242                .add_modifier(Modifier::BOLD),
243        )),
244    }
245}
246
247/// Render the file-history overlay in the diff column.
248pub fn render_file_history(app: &mut App, frame: &mut Frame, area: Rect) {
249    let theme = app.theme();
250    let is_active = app.active_pane == ActivePane::DiffView;
251    let border_color = if is_active {
252        theme.border_active
253    } else {
254        theme.border_inactive
255    };
256
257    let path = app.tab().file_history_path.clone().unwrap_or_default();
258    let file_name = path.rsplit('/').next().unwrap_or(&path).to_string();
259
260    let title = format!(" File History: {file_name}  Esc close  Enter select ");
261
262    let block = Block::default()
263        .title(title)
264        .borders(Borders::ALL)
265        .border_style(Style::default().fg(border_color));
266
267    let cursor = app.tab().file_history_cursor;
268    let commits = app.tab().file_history_commits.clone();
269
270    if commits.is_empty() {
271        let p = Paragraph::new(Line::from(Span::styled(
272            "Loading… (or no commits touch this file)",
273            Style::default().fg(theme.text_muted),
274        )))
275        .block(block);
276        frame.render_widget(p, area);
277        return;
278    }
279
280    let items: Vec<ListItem> = commits
281        .iter()
282        .enumerate()
283        .map(|(i, c)| {
284            let is_sel = i == cursor;
285            let style = if is_sel {
286                Style::default()
287                    .fg(theme.text_primary)
288                    .bg(theme.sel_bg)
289                    .add_modifier(Modifier::BOLD)
290            } else {
291                Style::default().fg(theme.text_primary)
292            };
293            let rel = c.relative_time();
294            let summary = gitkraft_core::truncate_str(&c.summary, 48);
295            let line = Line::from(vec![
296                Span::styled(
297                    format!("{} ", c.short_oid),
298                    Style::default().fg(theme.warning),
299                ),
300                Span::styled(summary, style),
301                Span::styled(
302                    format!(" ({}, {})", c.author_name, rel),
303                    Style::default().fg(theme.text_muted),
304                ),
305            ]);
306            ListItem::new(line)
307        })
308        .collect();
309
310    let list = List::new(items)
311        .block(block)
312        .highlight_style(
313            Style::default()
314                .bg(theme.sel_bg)
315                .add_modifier(Modifier::REVERSED),
316        )
317        .highlight_symbol("▶ ");
318
319    let mut list_state = ratatui::widgets::ListState::default();
320    list_state.select(Some(cursor));
321    frame.render_stateful_widget(list, area, &mut list_state);
322}
323
324/// Render the blame overlay in the diff column.
325pub fn render_blame(app: &mut App, frame: &mut Frame, area: Rect) {
326    let theme = app.theme();
327    let is_active = app.active_pane == ActivePane::DiffView;
328    let border_color = if is_active {
329        theme.border_active
330    } else {
331        theme.border_inactive
332    };
333
334    let path = app.tab().blame_path.clone().unwrap_or_default();
335    let file_name = path.rsplit('/').next().unwrap_or(&path).to_string();
336    let title = format!(" Blame: {file_name}  Esc close  j/k scroll ");
337
338    let block = Block::default()
339        .title(title)
340        .borders(Borders::ALL)
341        .border_style(Style::default().fg(border_color));
342
343    let lines_data = app.tab().blame_lines.clone();
344
345    if lines_data.is_empty() {
346        let p = Paragraph::new(Line::from(Span::styled(
347            "Loading blame…",
348            Style::default().fg(theme.text_muted),
349        )))
350        .block(block);
351        frame.render_widget(p, area);
352        return;
353    }
354
355    let lines: Vec<Line> = lines_data
356        .iter()
357        .map(|bl| {
358            let rel = bl.relative_time();
359            let author = gitkraft_core::truncate_str(&bl.author_name, 12);
360            Line::from(vec![
361                Span::styled(
362                    format!("{} ", bl.short_oid),
363                    Style::default().fg(theme.warning),
364                ),
365                Span::styled(
366                    format!("{:<12} ", author),
367                    Style::default().fg(theme.accent),
368                ),
369                Span::styled(
370                    format!("{:<8} ", rel),
371                    Style::default().fg(theme.text_muted),
372                ),
373                Span::styled(
374                    format!("{:>4}  ", bl.line_number),
375                    Style::default().fg(theme.text_muted),
376                ),
377                Span::styled(bl.content.clone(), Style::default().fg(theme.text_primary)),
378            ])
379        })
380        .collect();
381
382    // Clamp scroll
383    let content_height = lines.len() as u16;
384    let visible_height = area.height.saturating_sub(2);
385    {
386        let tab = app.tab_mut();
387        if content_height > visible_height {
388            if tab.blame_scroll > content_height.saturating_sub(visible_height) {
389                tab.blame_scroll = content_height.saturating_sub(visible_height);
390            }
391        } else {
392            tab.blame_scroll = 0;
393        }
394    }
395
396    let scroll = app.tab().blame_scroll;
397    let paragraph = Paragraph::new(lines)
398        .block(block)
399        .wrap(Wrap { trim: false })
400        .scroll((scroll, 0));
401
402    frame.render_widget(paragraph, area);
403}
404
405/// Render the diff content for the currently selected file (or all selected files).
406fn render_diff_content(
407    app: &mut App,
408    frame: &mut Frame,
409    area: Rect,
410    border_color: ratatui::style::Color,
411) {
412    let theme = app.theme();
413    let is_multi = app.tab().selected_file_indices.len() > 1;
414
415    if is_multi {
416        // ── Multi-file concatenated view ──────────────────────────────────
417        let mut sorted_indices: Vec<usize> =
418            app.tab().selected_file_indices.iter().copied().collect();
419        sorted_indices.sort();
420
421        let title = format!(" Diff ({} files) ", sorted_indices.len());
422        let block = Block::default()
423            .title(title)
424            .borders(Borders::ALL)
425            .border_style(Style::default().fg(border_color));
426
427        let mut lines: Vec<Line> = Vec::new();
428        for idx in &sorted_indices {
429            let file_name = app
430                .tab()
431                .commit_files
432                .get(*idx)
433                .map(|f| f.display_path().to_string())
434                .unwrap_or_else(|| format!("file {}", idx));
435
436            // File header separator
437            lines.push(Line::from(Span::styled(
438                format!("══ {} ══", file_name),
439                Style::default()
440                    .fg(theme.diff_hunk)
441                    .add_modifier(Modifier::BOLD),
442            )));
443
444            if let Some(diff) = app.tab().commit_diffs.get(idx).cloned() {
445                for hunk in &diff.hunks {
446                    for line in &hunk.lines {
447                        lines.push(styled_diff_line(line, &theme));
448                    }
449                }
450            } else {
451                lines.push(Line::from(Span::styled(
452                    "  Loading…",
453                    Style::default().fg(theme.text_muted),
454                )));
455            }
456            // Blank separator between files
457            lines.push(Line::default());
458        }
459
460        // Clamp scroll
461        let content_height = lines.len() as u16;
462        let visible_height = area.height.saturating_sub(2);
463        {
464            let tab = app.tab_mut();
465            if content_height > visible_height {
466                if tab.diff_scroll > content_height.saturating_sub(visible_height) {
467                    tab.diff_scroll = content_height.saturating_sub(visible_height);
468                }
469            } else {
470                tab.diff_scroll = 0;
471            }
472        }
473
474        let scroll = app.tab().diff_scroll;
475        let paragraph = Paragraph::new(lines)
476            .block(block)
477            .wrap(Wrap { trim: false })
478            .scroll((scroll, 0));
479
480        frame.render_widget(paragraph, area);
481        return;
482    }
483
484    // ── Single-file view ──────────────────────────────────────────────────
485    let tab = app.tab_mut();
486
487    let title = match &tab.selected_diff {
488        Some(diff) => {
489            let name = diff.display_path();
490            if name.is_empty() {
491                " Diff ".to_string()
492            } else {
493                format!(" Diff: {} ", name)
494            }
495        }
496        None => " Diff ".to_string(),
497    };
498
499    let block = Block::default()
500        .title(title)
501        .borders(Borders::ALL)
502        .border_style(Style::default().fg(border_color));
503
504    match &tab.selected_diff {
505        None => {
506            let placeholder = Paragraph::new(Line::from(vec![Span::styled(
507                "Select a commit or file to view diff",
508                Style::default().fg(theme.text_muted),
509            )]))
510            .block(block)
511            .alignment(ratatui::layout::Alignment::Center);
512
513            frame.render_widget(placeholder, area);
514        }
515        Some(diff) => {
516            let mut lines: Vec<Line> = Vec::new();
517
518            for hunk in &diff.hunks {
519                for line in &hunk.lines {
520                    lines.push(styled_diff_line(line, &theme));
521                }
522            }
523
524            // Clamp scroll so it doesn't go past the content
525            let content_height = lines.len() as u16;
526            let visible_height = area.height.saturating_sub(2); // subtract border rows
527            if content_height > visible_height {
528                if tab.diff_scroll > content_height.saturating_sub(visible_height) {
529                    tab.diff_scroll = content_height.saturating_sub(visible_height);
530                }
531            } else {
532                tab.diff_scroll = 0;
533            }
534
535            let paragraph = Paragraph::new(lines)
536                .block(block)
537                .wrap(Wrap { trim: false })
538                .scroll((tab.diff_scroll, 0));
539
540            frame.render_widget(paragraph, area);
541        }
542    }
543}