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};
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    let theme = app.theme();
19    let is_active = app.active_pane == ActivePane::DiffView;
20    let border_color = if is_active {
21        theme.border_active
22    } else {
23        theme.border_inactive
24    };
25
26    // Split into file list + diff content when there are multiple files
27    if !app.tab().commit_files.is_empty() {
28        let chunks = Layout::default()
29            .direction(Direction::Horizontal)
30            .constraints([Constraint::Length(30), Constraint::Min(20)])
31            .split(area);
32
33        render_file_list(app, frame, chunks[0], border_color);
34        render_diff_content(app, frame, chunks[1], border_color);
35    } else {
36        render_diff_content(app, frame, area, border_color);
37    }
38}
39
40/// Render the file list sidebar for the current commit's changed files.
41fn render_file_list(
42    app: &mut App,
43    frame: &mut Frame,
44    area: Rect,
45    border_color: ratatui::style::Color,
46) {
47    let theme = app.theme();
48    let tab = app.tab();
49    let commit_diff_file_index = tab.commit_diff_file_index;
50
51    let block = Block::default()
52        .title(format!(" Files ({}) ", tab.commit_files.len()))
53        .borders(Borders::ALL)
54        .border_style(Style::default().fg(border_color));
55
56    let items: Vec<ListItem> = tab
57        .commit_files
58        .iter()
59        .enumerate()
60        .map(|(i, diff)| {
61            let is_selected = i == commit_diff_file_index;
62            let file_name = diff.file_name();
63            let status_char = format!("{}", diff.status);
64
65            let status_color = match diff.status.color_category() {
66                gitkraft_core::StatusColorCategory::Added => theme.success,
67                gitkraft_core::StatusColorCategory::Modified => theme.warning,
68                gitkraft_core::StatusColorCategory::Deleted => theme.error,
69                gitkraft_core::StatusColorCategory::Renamed => theme.accent,
70            };
71
72            let name_color = if is_selected {
73                theme.text_primary
74            } else {
75                theme.text_secondary
76            };
77
78            let line = Line::from(vec![
79                Span::styled(
80                    format!("{} ", status_char),
81                    Style::default()
82                        .fg(status_color)
83                        .add_modifier(Modifier::BOLD),
84                ),
85                Span::styled(file_name.to_string(), Style::default().fg(name_color)),
86            ]);
87
88            ListItem::new(line)
89        })
90        .collect();
91
92    let mut list_state = ratatui::widgets::ListState::default();
93    list_state.select(Some(commit_diff_file_index));
94
95    let list = List::new(items)
96        .block(block)
97        .highlight_style(
98            Style::default()
99                .bg(theme.sel_bg)
100                .add_modifier(Modifier::REVERSED),
101        )
102        .highlight_symbol("▸ ");
103
104    frame.render_stateful_widget(list, area, &mut list_state);
105}
106
107/// Render the diff content for the currently selected file.
108fn render_diff_content(
109    app: &mut App,
110    frame: &mut Frame,
111    area: Rect,
112    border_color: ratatui::style::Color,
113) {
114    let theme = app.theme();
115    let tab = app.tab_mut();
116
117    let title = match &tab.selected_diff {
118        Some(diff) => {
119            let name = diff.display_path();
120            if name.is_empty() {
121                " Diff ".to_string()
122            } else {
123                format!(" Diff: {} ", name)
124            }
125        }
126        None => " Diff ".to_string(),
127    };
128
129    let block = Block::default()
130        .title(title)
131        .borders(Borders::ALL)
132        .border_style(Style::default().fg(border_color));
133
134    match &tab.selected_diff {
135        None => {
136            let placeholder = Paragraph::new(Line::from(vec![Span::styled(
137                "Select a commit or file to view diff",
138                Style::default().fg(theme.text_muted),
139            )]))
140            .block(block)
141            .alignment(ratatui::layout::Alignment::Center);
142
143            frame.render_widget(placeholder, area);
144        }
145        Some(diff) => {
146            let mut lines: Vec<Line> = Vec::new();
147
148            for hunk in &diff.hunks {
149                for line in &hunk.lines {
150                    let styled_line = match line {
151                        DiffLine::Addition(s) => Line::from(Span::styled(
152                            format!("+{}", s),
153                            Style::default().fg(theme.diff_add),
154                        )),
155                        DiffLine::Deletion(s) => Line::from(Span::styled(
156                            format!("-{}", s),
157                            Style::default().fg(theme.diff_del),
158                        )),
159                        DiffLine::Context(s) => Line::from(Span::styled(
160                            format!(" {}", s),
161                            Style::default().fg(theme.diff_context),
162                        )),
163                        DiffLine::HunkHeader(s) => Line::from(Span::styled(
164                            s.clone(),
165                            Style::default()
166                                .fg(theme.diff_hunk)
167                                .add_modifier(Modifier::BOLD),
168                        )),
169                    };
170                    lines.push(styled_line);
171                }
172            }
173
174            // Clamp scroll so it doesn't go past the content
175            let content_height = lines.len() as u16;
176            let visible_height = area.height.saturating_sub(2); // subtract border rows
177            if content_height > visible_height {
178                if tab.diff_scroll > content_height.saturating_sub(visible_height) {
179                    tab.diff_scroll = content_height.saturating_sub(visible_height);
180                }
181            } else {
182                tab.diff_scroll = 0;
183            }
184
185            let paragraph = Paragraph::new(lines)
186                .block(block)
187                .wrap(Wrap { trim: false })
188                .scroll((tab.diff_scroll, 0));
189
190            frame.render_widget(paragraph, area);
191        }
192    }
193}