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