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, mouse_area, 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.is_empty() {
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::new().width(4),
113        header_text,
114        Space::new().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    let oid_for_menu = tab.selected_commit_oid.clone().unwrap_or_default();
122
123    for (idx, diff) in tab.commit_files.iter().enumerate() {
124        // Extract just the filename for a compact display
125        let file_name = diff.file_name();
126
127        // Determine if this file is the currently selected one
128        let is_selected = tab.selected_file_index == Some(idx);
129
130        let status_color = theme::status_color(&diff.status, c);
131
132        let status_char = format!("{}", diff.status);
133
134        let status_badge = text(status_char)
135            .size(11)
136            .font(Font::MONOSPACE)
137            .color(status_color);
138
139        let name_color = if is_selected {
140            c.text_primary
141        } else {
142            c.text_secondary
143        };
144
145        let name_label = text(file_name.to_string())
146            .size(12)
147            .color(name_color)
148            .wrapping(iced::widget::text::Wrapping::None);
149
150        // Show parent directory as a subtle hint when names might be ambiguous
151        let dir_hint: Element<'a, Message> = {
152            let short_dir = diff.short_parent_dir();
153            if short_dir.is_empty() {
154                Space::new().into()
155            } else {
156                text(format!("{short_dir}/"))
157                    .size(10)
158                    .color(c.muted)
159                    .wrapping(iced::widget::text::Wrapping::None)
160                    .into()
161            }
162        };
163
164        let row_content = row![
165            status_badge,
166            Space::new().width(4),
167            column![row![dir_hint, name_label].align_y(Alignment::Center),],
168        ]
169        .align_y(Alignment::Center)
170        .padding([4, 8])
171        .width(Length::Fill);
172
173        let style_fn = if is_selected {
174            theme::selected_row_style as fn(&iced::Theme) -> iced::widget::container::Style
175        } else {
176            theme::surface_style as fn(&iced::Theme) -> iced::widget::container::Style
177        };
178
179        let file_btn = button(row_content)
180            .padding(0)
181            .width(Length::Fill)
182            .style(theme::ghost_button)
183            .on_press(Message::SelectDiffByIndex(idx));
184
185        let file_row = mouse_area(
186            container(file_btn)
187                .width(Length::Fill)
188                .height(Length::Fixed(26.0))
189                .clip(true)
190                .style(style_fn),
191        )
192        .on_right_press(Message::OpenCommitFileContextMenu(
193            oid_for_menu.clone(),
194            diff.display_path().to_string(),
195        ));
196
197        file_list_col = file_list_col.push(file_row);
198    }
199
200    let scrollable_files = scrollable(file_list_col)
201        .height(Length::Fill)
202        .direction(view_utils::thin_scrollbar())
203        .style(crate::theme::overlay_scrollbar);
204
205    container(
206        column![header_row, scrollable_files]
207            .width(Length::Fill)
208            .height(Length::Fill),
209    )
210    .width(Length::Fixed(width))
211    .height(Length::Fill)
212    .style(theme::sidebar_style)
213    .into()
214}
215
216/// Placeholder shown when no diff is selected.
217fn placeholder_view<'a>(c: &ThemeColors) -> Element<'a, Message> {
218    view_utils::centered_placeholder(
219        icons::FILE_DIFF,
220        32,
221        "Select a commit or file to view diff",
222        c.muted,
223    )
224}
225
226/// Loading indicator shown while a single file’s diff is being fetched.
227fn loading_diff_view<'a>(c: &ThemeColors) -> Element<'a, Message> {
228    view_utils::centered_placeholder(icons::ARROW_REPEAT, 24, "Loading diff…", c.muted)
229}
230
231/// Render the full diff content for a single [`DiffInfo`] with virtual
232/// scrolling. Only lines within (or near) the visible viewport are
233/// materialised as widgets.
234fn diff_content<'a>(
235    diff: &'a DiffInfo,
236    c: &ThemeColors,
237    scroll_offset: f32,
238) -> Element<'a, Message> {
239    let file_path_display = diff.display_path().to_string();
240
241    let status_color = theme::status_color(&diff.status, c);
242
243    let status_badge = text(format!(" {} ", diff.status))
244        .size(12)
245        .color(status_color)
246        .font(Font::MONOSPACE);
247
248    let file_label = text(file_path_display)
249        .size(14)
250        .color(c.text_primary)
251        .font(Font::MONOSPACE);
252
253    let file_header = container(
254        row![status_badge, Space::new().width(8), file_label].align_y(iced::Alignment::Center),
255    )
256    .padding([8, 12])
257    .width(Length::Fill)
258    .style(theme::header_style);
259
260    let mut lines_col = column![].width(Length::Fill);
261
262    if diff.hunks.is_empty() {
263        let empty_msg = text("No diff content available.")
264            .size(13)
265            .color(c.muted)
266            .font(Font::MONOSPACE);
267        lines_col = lines_col.push(container(empty_msg).padding([8, 12]));
268    } else {
269        // Flatten all lines for virtual scrolling
270        let total_lines: usize = diff.hunks.iter().map(|h| h.lines.len()).sum();
271
272        let first = ((scroll_offset / DIFF_LINE_HEIGHT) as usize).saturating_sub(DIFF_OVERSCAN);
273        let last = (first + DIFF_VISIBLE_LINES + 2 * DIFF_OVERSCAN).min(total_lines);
274
275        let top_space = first as f32 * DIFF_LINE_HEIGHT;
276        let bottom_space = (total_lines - last) as f32 * DIFF_LINE_HEIGHT;
277
278        if top_space > 0.0 {
279            lines_col = lines_col.push(Space::new().height(top_space));
280        }
281
282        // Iterate through hunks to find lines in range [first..last)
283        let mut global_idx = 0usize;
284        for hunk in &diff.hunks {
285            for line in &hunk.lines {
286                if global_idx >= first && global_idx < last {
287                    lines_col = lines_col.push(render_line(line, c));
288                }
289                global_idx += 1;
290                if global_idx >= last {
291                    break;
292                }
293            }
294            if global_idx >= last {
295                break;
296            }
297        }
298
299        if bottom_space > 0.0 {
300            lines_col = lines_col.push(Space::new().height(bottom_space));
301        }
302    }
303
304    let scrollable_content = scrollable(lines_col)
305        .height(Length::Fill)
306        .on_scroll(|vp| Message::DiffViewScrolled(vp.absolute_offset().y))
307        .direction(view_utils::thin_scrollbar())
308        .style(crate::theme::overlay_scrollbar);
309
310    column![file_header, scrollable_content]
311        .width(Length::Fill)
312        .height(Length::Fill)
313        .into()
314}
315
316/// Build a single diff line widget: prefix + content, monospace, colored, with optional background.
317fn diff_line_widget<'a>(
318    prefix: &'static str,
319    content_str: &str,
320    color: iced::Color,
321    style: Option<fn(&iced::Theme) -> iced::widget::container::Style>,
322) -> Element<'a, Message> {
323    let prefix_w = text(prefix).size(13).font(Font::MONOSPACE).color(color);
324    let content = text(content_str.to_string())
325        .size(13)
326        .font(Font::MONOSPACE)
327        .color(color);
328    let c = container(row![prefix_w, Space::new().width(4), content])
329        .padding([1, 12])
330        .width(Length::Fill);
331    match style {
332        Some(s) => c.style(s).into(),
333        None => c.into(),
334    }
335}
336
337/// Render a single [`DiffLine`] as a styled container with monospace text.
338fn render_line<'a>(line: &DiffLine, c: &ThemeColors) -> Element<'a, Message> {
339    match line {
340        DiffLine::HunkHeader(header) => {
341            let content = text(header.clone())
342                .size(13)
343                .font(Font::MONOSPACE)
344                .color(c.accent);
345            container(content)
346                .padding([4, 12])
347                .width(Length::Fill)
348                .style(theme::diff_hunk_style)
349                .into()
350        }
351        DiffLine::Addition(s) => diff_line_widget("+", s, c.green, Some(theme::diff_add_style)),
352        DiffLine::Deletion(s) => diff_line_widget("-", s, c.red, Some(theme::diff_del_style)),
353        DiffLine::Context(s) => diff_line_widget(" ", s, c.text_secondary, None),
354    }
355}