Skip to main content

gitkraft_gui/features/diff/
view.rs

1//! Diff viewer panel — shows file diffs with colored hunks, monospace text,
2//! and hunk headers highlighted.
3//!
4//! When a commit is selected and its diff contains multiple files, a clickable
5//! file list is shown on the left side of the diff panel so the user can
6//! switch between files.
7//!
8//! The diff content uses virtual scrolling: only the lines that fall within
9//! (or near) the visible viewport are materialised as widgets, keeping the
10//! widget tree small even for multi-thousand-line diffs.
11
12use gitkraft_core::{DiffInfo, DiffLine};
13use iced::widget::{button, column, container, row, scrollable, text, Space};
14use iced::{Alignment, Element, Font, Length};
15
16use crate::icons;
17use crate::message::Message;
18use crate::state::GitKraft;
19use crate::theme;
20use crate::theme::ThemeColors;
21use crate::view_utils;
22
23/// Estimated height of one diff line in pixels.
24const DIFF_LINE_HEIGHT: f32 = 22.0;
25/// Lines rendered above and below the visible window.
26const DIFF_OVERSCAN: usize = 20;
27/// Assumed visible lines (covers a tall viewport).
28const DIFF_VISIBLE_LINES: usize = 60;
29
30/// Render the diff viewer panel. If a diff is selected, render its hunks with
31/// colored lines; otherwise show a placeholder message.
32pub fn view(state: &GitKraft) -> Element<'_, Message> {
33    let c = state.colors();
34    let tab = state.active_tab();
35
36    match &tab.selected_diff {
37        Some(diff) => {
38            if tab.commit_files.len() > 1 {
39                // Multiple files in this commit — show file list + divider + diff side by side.
40                let file_list = commit_file_list(state, &c, state.diff_file_list_width);
41                let divider = crate::widgets::divider::vertical_divider(
42                    crate::state::DragTarget::DiffFileListRight,
43                    &c,
44                );
45                let diff_panel = diff_content(diff, &c, tab.diff_scroll_offset);
46
47                let layout = row![file_list, divider, diff_panel]
48                    .width(Length::Fill)
49                    .height(Length::Fill);
50
51                container(layout)
52                    .width(Length::Fill)
53                    .height(Length::Fill)
54                    .style(theme::surface_style)
55                    .into()
56            } else {
57                // Single file (or staging diff) — show diff only.
58                container(diff_content(diff, &c, tab.diff_scroll_offset))
59                    .width(Length::Fill)
60                    .height(Length::Fill)
61                    .style(theme::surface_style)
62                    .into()
63            }
64        }
65        None => {
66            if !tab.commit_files.is_empty() {
67                // Files loaded but no diff selected yet — show file list + loading/placeholder.
68                let file_list = commit_file_list(state, &c, state.diff_file_list_width);
69                let divider = crate::widgets::divider::vertical_divider(
70                    crate::state::DragTarget::DiffFileListRight,
71                    &c,
72                );
73                let right_panel = if tab.is_loading_file_diff {
74                    loading_diff_view(&c)
75                } else {
76                    placeholder_view(&c)
77                };
78                container(
79                    row![file_list, divider, right_panel]
80                        .width(Length::Fill)
81                        .height(Length::Fill),
82                )
83                .width(Length::Fill)
84                .height(Length::Fill)
85                .style(theme::surface_style)
86                .into()
87            } else {
88                container(placeholder_view(&c))
89                    .width(Length::Fill)
90                    .height(Length::Fill)
91                    .style(theme::surface_style)
92                    .into()
93            }
94        }
95    }
96}
97
98/// Clickable file list for the currently selected commit's diffs.
99fn commit_file_list<'a>(state: &'a GitKraft, c: &ThemeColors, width: f32) -> Element<'a, Message> {
100    let tab = state.active_tab();
101
102    let header_icon = icon!(icons::FILE_DIFF, 13, c.accent);
103
104    let header_text = text("Files").size(13).color(c.text_primary);
105
106    let file_count = text(format!("({})", tab.commit_files.len()))
107        .size(11)
108        .color(c.muted);
109
110    let header_row = row![
111        header_icon,
112        Space::with_width(4),
113        header_text,
114        Space::with_width(4),
115        file_count,
116    ]
117    .align_y(Alignment::Center)
118    .padding([6, 8]);
119
120    let mut file_list_col = column![].spacing(1).width(Length::Fill);
121
122    for (idx, diff) in tab.commit_files.iter().enumerate() {
123        // Extract just the filename for a compact display
124        let file_name = diff.file_name();
125
126        // Determine if this file is the currently selected one
127        let is_selected = tab.selected_file_index == Some(idx);
128
129        let status_color = theme::status_color(&diff.status, c);
130
131        let status_char = format!("{}", diff.status);
132
133        let status_badge = text(status_char)
134            .size(11)
135            .font(Font::MONOSPACE)
136            .color(status_color);
137
138        let name_color = if is_selected {
139            c.text_primary
140        } else {
141            c.text_secondary
142        };
143
144        let name_label = text(file_name.to_string())
145            .size(12)
146            .color(name_color)
147            .wrapping(iced::widget::text::Wrapping::None);
148
149        // Show parent directory as a subtle hint when names might be ambiguous
150        let dir_hint: Element<'a, Message> = {
151            let short_dir = diff.short_parent_dir();
152            if short_dir.is_empty() {
153                Space::with_width(0).into()
154            } else {
155                text(format!("{short_dir}/"))
156                    .size(10)
157                    .color(c.muted)
158                    .wrapping(iced::widget::text::Wrapping::None)
159                    .into()
160            }
161        };
162
163        let row_content = row![
164            status_badge,
165            Space::with_width(4),
166            column![row![dir_hint, name_label].align_y(Alignment::Center),],
167        ]
168        .align_y(Alignment::Center)
169        .padding([4, 8])
170        .width(Length::Fill);
171
172        let style_fn = if is_selected {
173            theme::selected_row_style as fn(&iced::Theme) -> iced::widget::container::Style
174        } else {
175            theme::surface_style as fn(&iced::Theme) -> iced::widget::container::Style
176        };
177
178        let file_btn = button(row_content)
179            .padding(0)
180            .width(Length::Fill)
181            .style(theme::ghost_button)
182            .on_press(Message::SelectDiffByIndex(idx));
183
184        let file_row = container(file_btn)
185            .width(Length::Fill)
186            .height(Length::Fixed(26.0))
187            .clip(true)
188            .style(style_fn);
189
190        file_list_col = file_list_col.push(file_row);
191    }
192
193    let scrollable_files = scrollable(file_list_col)
194        .height(Length::Fill)
195        .direction(view_utils::thin_scrollbar())
196        .style(crate::theme::overlay_scrollbar);
197
198    container(
199        column![header_row, scrollable_files]
200            .width(Length::Fill)
201            .height(Length::Fill),
202    )
203    .width(Length::Fixed(width))
204    .height(Length::Fill)
205    .style(theme::sidebar_style)
206    .into()
207}
208
209/// Placeholder shown when no diff is selected.
210fn placeholder_view<'a>(c: &ThemeColors) -> Element<'a, Message> {
211    view_utils::centered_placeholder(
212        icons::FILE_DIFF,
213        32,
214        "Select a commit or file to view diff",
215        c.muted,
216    )
217}
218
219/// Loading indicator shown while a single file’s diff is being fetched.
220fn loading_diff_view<'a>(c: &ThemeColors) -> Element<'a, Message> {
221    view_utils::centered_placeholder(icons::ARROW_REPEAT, 24, "Loading diff…", c.muted)
222}
223
224/// Render the full diff content for a single [`DiffInfo`] with virtual
225/// scrolling. Only lines within (or near) the visible viewport are
226/// materialised as widgets.
227fn diff_content<'a>(
228    diff: &'a DiffInfo,
229    c: &ThemeColors,
230    scroll_offset: f32,
231) -> Element<'a, Message> {
232    let file_path_display = diff.display_path().to_string();
233
234    let status_color = theme::status_color(&diff.status, c);
235
236    let status_badge = text(format!(" {} ", diff.status))
237        .size(12)
238        .color(status_color)
239        .font(Font::MONOSPACE);
240
241    let file_label = text(file_path_display)
242        .size(14)
243        .color(c.text_primary)
244        .font(Font::MONOSPACE);
245
246    let file_header = container(
247        row![status_badge, Space::with_width(8), file_label].align_y(iced::Alignment::Center),
248    )
249    .padding([8, 12])
250    .width(Length::Fill)
251    .style(theme::header_style);
252
253    let mut lines_col = column![].width(Length::Fill);
254
255    if diff.hunks.is_empty() {
256        let empty_msg = text("No diff content available.")
257            .size(13)
258            .color(c.muted)
259            .font(Font::MONOSPACE);
260        lines_col = lines_col.push(container(empty_msg).padding([8, 12]));
261    } else {
262        // Flatten all lines for virtual scrolling
263        let total_lines: usize = diff.hunks.iter().map(|h| h.lines.len()).sum();
264
265        let first = ((scroll_offset / DIFF_LINE_HEIGHT) as usize).saturating_sub(DIFF_OVERSCAN);
266        let last = (first + DIFF_VISIBLE_LINES + 2 * DIFF_OVERSCAN).min(total_lines);
267
268        let top_space = first as f32 * DIFF_LINE_HEIGHT;
269        let bottom_space = (total_lines - last) as f32 * DIFF_LINE_HEIGHT;
270
271        if top_space > 0.0 {
272            lines_col = lines_col.push(Space::with_height(top_space));
273        }
274
275        // Iterate through hunks to find lines in range [first..last)
276        let mut global_idx = 0usize;
277        for hunk in &diff.hunks {
278            for line in &hunk.lines {
279                if global_idx >= first && global_idx < last {
280                    lines_col = lines_col.push(render_line(line, c));
281                }
282                global_idx += 1;
283                if global_idx >= last {
284                    break;
285                }
286            }
287            if global_idx >= last {
288                break;
289            }
290        }
291
292        if bottom_space > 0.0 {
293            lines_col = lines_col.push(Space::with_height(bottom_space));
294        }
295    }
296
297    let scrollable_content = scrollable(lines_col)
298        .height(Length::Fill)
299        .on_scroll(|vp| Message::DiffViewScrolled(vp.absolute_offset().y))
300        .direction(view_utils::thin_scrollbar())
301        .style(crate::theme::overlay_scrollbar);
302
303    column![file_header, scrollable_content]
304        .width(Length::Fill)
305        .height(Length::Fill)
306        .into()
307}
308
309/// Build a single diff line widget: prefix + content, monospace, colored, with optional background.
310fn diff_line_widget<'a>(
311    prefix: &'static str,
312    content_str: &str,
313    color: iced::Color,
314    style: Option<fn(&iced::Theme) -> iced::widget::container::Style>,
315) -> Element<'a, Message> {
316    let prefix_w = text(prefix).size(13).font(Font::MONOSPACE).color(color);
317    let content = text(content_str.to_string())
318        .size(13)
319        .font(Font::MONOSPACE)
320        .color(color);
321    let c = container(row![prefix_w, Space::with_width(4), content])
322        .padding([1, 12])
323        .width(Length::Fill);
324    match style {
325        Some(s) => c.style(s).into(),
326        None => c.into(),
327    }
328}
329
330/// Render a single [`DiffLine`] as a styled container with monospace text.
331fn render_line<'a>(line: &DiffLine, c: &ThemeColors) -> Element<'a, Message> {
332    match line {
333        DiffLine::HunkHeader(header) => {
334            let content = text(header.clone())
335                .size(13)
336                .font(Font::MONOSPACE)
337                .color(c.accent);
338            container(content)
339                .padding([4, 12])
340                .width(Length::Fill)
341                .style(theme::diff_hunk_style)
342                .into()
343        }
344        DiffLine::Addition(s) => diff_line_widget("+", s, c.green, Some(theme::diff_add_style)),
345        DiffLine::Deletion(s) => diff_line_widget("-", s, c.red, Some(theme::diff_del_style)),
346        DiffLine::Context(s) => diff_line_widget(" ", s, c.text_secondary, None),
347    }
348}