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    // ── Priority 1: commit range diff (multiple commits selected) ─────────
37    if !tab.commit_range_diffs.is_empty() {
38        let multi_panel = multi_diff_content(&tab.commit_range_diffs, &c, tab.diff_scroll_offset);
39        return container(multi_panel)
40            .width(Length::Fill)
41            .height(Length::Fill)
42            .style(theme::surface_style)
43            .into();
44    }
45
46    // ── Priority 2: loading indicator for range diff ───────────────────────
47    if tab.selected_commits.len() > 1 && tab.is_loading_file_diff {
48        return container(loading_diff_view(&c))
49            .width(Length::Fill)
50            .height(Length::Fill)
51            .style(theme::surface_style)
52            .into();
53    }
54
55    // ── Priority 3: multi-file diff (existing code unchanged below) ────────
56    // Multi-file mode: show concatenated diffs for all selected files.
57    if !tab.multi_file_diffs.is_empty() {
58        let file_list = commit_file_list(state, &c, state.diff_file_list_width);
59        let divider = crate::widgets::divider::vertical_divider(
60            crate::state::DragTarget::DiffFileListRight,
61            &c,
62        );
63        let multi_panel = multi_diff_content(&tab.multi_file_diffs, &c, tab.diff_scroll_offset);
64        return container(
65            row![file_list, divider, multi_panel]
66                .width(Length::Fill)
67                .height(Length::Fill),
68        )
69        .width(Length::Fill)
70        .height(Length::Fill)
71        .style(theme::surface_style)
72        .into();
73    }
74
75    match &tab.selected_diff {
76        Some(diff) => {
77            if !tab.commit_files.is_empty() {
78                // Multiple files in this commit — show file list + divider + diff side by side.
79                let file_list = commit_file_list(state, &c, state.diff_file_list_width);
80                let divider = crate::widgets::divider::vertical_divider(
81                    crate::state::DragTarget::DiffFileListRight,
82                    &c,
83                );
84                let diff_panel = diff_content(diff, &c, tab.diff_scroll_offset);
85
86                let layout = row![file_list, divider, diff_panel]
87                    .width(Length::Fill)
88                    .height(Length::Fill);
89
90                container(layout)
91                    .width(Length::Fill)
92                    .height(Length::Fill)
93                    .style(theme::surface_style)
94                    .into()
95            } else {
96                // Single file (or staging diff) — show diff only.
97                container(diff_content(diff, &c, tab.diff_scroll_offset))
98                    .width(Length::Fill)
99                    .height(Length::Fill)
100                    .style(theme::surface_style)
101                    .into()
102            }
103        }
104        None => {
105            if !tab.commit_files.is_empty() {
106                // Files loaded but no diff selected yet — show file list + loading/placeholder.
107                let file_list = commit_file_list(state, &c, state.diff_file_list_width);
108                let divider = crate::widgets::divider::vertical_divider(
109                    crate::state::DragTarget::DiffFileListRight,
110                    &c,
111                );
112                let right_panel = if tab.is_loading_file_diff {
113                    loading_diff_view(&c)
114                } else {
115                    placeholder_view(&c)
116                };
117                container(
118                    row![file_list, divider, right_panel]
119                        .width(Length::Fill)
120                        .height(Length::Fill),
121                )
122                .width(Length::Fill)
123                .height(Length::Fill)
124                .style(theme::surface_style)
125                .into()
126            } else {
127                container(placeholder_view(&c))
128                    .width(Length::Fill)
129                    .height(Length::Fill)
130                    .style(theme::surface_style)
131                    .into()
132            }
133        }
134    }
135}
136
137/// Clickable file list for the currently selected commit's diffs.
138fn commit_file_list<'a>(state: &'a GitKraft, c: &ThemeColors, width: f32) -> Element<'a, Message> {
139    let tab = state.active_tab();
140
141    let header_icon = icon!(icons::FILE_DIFF, 13, c.accent);
142
143    let header_text = text("Files").size(13).color(c.text_primary);
144
145    let multi_count = tab.selected_commit_file_indices.len();
146    let file_count = if multi_count > 1 {
147        text(format!("({} selected)", multi_count))
148            .size(11)
149            .color(c.accent)
150    } else {
151        text(format!("({})", tab.commit_files.len()))
152            .size(11)
153            .color(c.muted)
154    };
155
156    let header_row = row![
157        header_icon,
158        Space::new().width(4),
159        header_text,
160        Space::new().width(4),
161        file_count,
162    ]
163    .align_y(Alignment::Center)
164    .padding([6, 8]);
165
166    let mut file_list_col = column![].spacing(1).width(Length::Fill);
167    let oid_for_menu = tab.selected_commit_oid.clone().unwrap_or_default();
168
169    for (idx, diff) in tab.commit_files.iter().enumerate() {
170        // Extract just the filename for a compact display
171        let file_name = diff.file_name();
172
173        // Determine if this file is the primary selected one or part of a multi-selection
174        let is_selected = tab.selected_file_index == Some(idx);
175        let is_multi_selected = tab.selected_commit_file_indices.contains(&idx);
176
177        let status_color = theme::status_color(&diff.status, c);
178
179        let status_char = format!("{}", diff.status);
180
181        let status_badge = text(status_char)
182            .size(11)
183            .font(Font::MONOSPACE)
184            .color(status_color);
185
186        let name_color = if is_selected || is_multi_selected {
187            c.text_primary
188        } else {
189            c.text_secondary
190        };
191
192        let name_label = text(file_name.to_string())
193            .size(12)
194            .color(name_color)
195            .wrapping(iced::widget::text::Wrapping::None);
196
197        // Show parent directory as a subtle hint when names might be ambiguous
198        let dir_hint: Element<'a, Message> = {
199            let short_dir = diff.short_parent_dir();
200            if short_dir.is_empty() {
201                Space::new().into()
202            } else {
203                text(format!("{short_dir}/"))
204                    .size(10)
205                    .color(c.muted)
206                    .wrapping(iced::widget::text::Wrapping::None)
207                    .into()
208            }
209        };
210
211        // Position of this file in the selection order (0-based → display as 1-based)
212        let selection_badge: Element<'a, Message> = if let Some(pos) = tab
213            .selected_commit_file_indices
214            .iter()
215            .position(|&i| i == idx)
216        {
217            container(
218                text(format!("{}", pos + 1))
219                    .size(10)
220                    .font(Font::MONOSPACE)
221                    .color(c.accent),
222            )
223            .width(16)
224            .center_x(Length::Fixed(16.0))
225            .into()
226        } else {
227            Space::new().width(16).into()
228        };
229
230        let row_content = row![
231            selection_badge,
232            Space::new().width(2),
233            status_badge,
234            Space::new().width(4),
235            column![row![dir_hint, name_label].align_y(Alignment::Center),],
236        ]
237        .align_y(Alignment::Center)
238        .padding([4, 8])
239        .width(Length::Fill);
240
241        // Primary selected row gets the strongest highlight; multi-selected-but-not-primary
242        // gets a distinct secondary highlight; unselected rows use the plain surface style.
243        let style_fn = if is_selected {
244            theme::selected_row_style as fn(&iced::Theme) -> iced::widget::container::Style
245        } else if is_multi_selected {
246            theme::highlight_row_style as fn(&iced::Theme) -> iced::widget::container::Style
247        } else {
248            theme::surface_style as fn(&iced::Theme) -> iced::widget::container::Style
249        };
250
251        let file_btn = button(row_content)
252            .padding(0)
253            .width(Length::Fill)
254            .style(theme::ghost_button)
255            .on_press(Message::SelectDiffByIndex(idx));
256
257        let file_row = mouse_area(
258            container(file_btn)
259                .width(Length::Fill)
260                .height(Length::Fixed(26.0))
261                .clip(true)
262                .style(style_fn),
263        )
264        .on_right_press(Message::OpenCommitFileContextMenu(
265            oid_for_menu.clone(),
266            diff.display_path().to_string(),
267        ));
268
269        file_list_col = file_list_col.push(file_row);
270    }
271
272    let scrollable_files = scrollable(file_list_col)
273        .height(Length::Fill)
274        .direction(view_utils::thin_scrollbar())
275        .style(crate::theme::overlay_scrollbar);
276
277    container(
278        column![header_row, scrollable_files]
279            .width(Length::Fill)
280            .height(Length::Fill),
281    )
282    .width(Length::Fixed(width))
283    .height(Length::Fill)
284    .style(theme::sidebar_style)
285    .into()
286}
287
288/// Render concatenated diffs for multiple selected files, separated by per-file
289/// headers showing the file path and status.
290fn multi_diff_content<'a>(
291    diffs: &'a [DiffInfo],
292    c: &ThemeColors,
293    _scroll_offset: f32,
294) -> Element<'a, Message> {
295    let mut col = column![].width(Length::Fill);
296
297    for diff in diffs {
298        // Per-file header bar
299        let status_color = theme::status_color(&diff.status, c);
300        let header = container(
301            row![
302                text(format!(" {} ", diff.status))
303                    .size(12)
304                    .color(status_color)
305                    .font(Font::MONOSPACE),
306                Space::new().width(8),
307                text(diff.display_path().to_string())
308                    .size(13)
309                    .color(c.text_primary)
310                    .font(Font::MONOSPACE),
311            ]
312            .align_y(Alignment::Center),
313        )
314        .padding([6, 12])
315        .width(Length::Fill)
316        .style(theme::header_style);
317
318        col = col.push(header);
319
320        if diff.hunks.is_empty() {
321            col = col.push(
322                container(
323                    text("No diff content.")
324                        .size(13)
325                        .color(c.muted)
326                        .font(Font::MONOSPACE),
327                )
328                .padding([4, 12]),
329            );
330        } else {
331            for hunk in &diff.hunks {
332                for line in &hunk.lines {
333                    col = col.push(render_line(line, c));
334                }
335            }
336        }
337
338        // Small gap between files
339        col = col.push(Space::new().height(8));
340    }
341
342    scrollable(col)
343        .height(Length::Fill)
344        .on_scroll(|vp| Message::DiffViewScrolled(vp.absolute_offset().y))
345        .direction(view_utils::thin_scrollbar())
346        .style(crate::theme::overlay_scrollbar)
347        .into()
348}
349
350/// Placeholder shown when no diff is selected.
351fn placeholder_view<'a>(c: &ThemeColors) -> Element<'a, Message> {
352    view_utils::centered_placeholder(
353        icons::FILE_DIFF,
354        32,
355        "Select a commit or file to view diff",
356        c.muted,
357    )
358}
359
360/// Loading indicator shown while a single file's diff is being fetched.
361fn loading_diff_view<'a>(c: &ThemeColors) -> Element<'a, Message> {
362    view_utils::centered_placeholder(icons::ARROW_REPEAT, 24, "Loading diff…", c.muted)
363}
364
365/// Render the full diff content for a single [`DiffInfo`] with virtual
366/// scrolling. Only lines within (or near) the visible viewport are
367/// materialised as widgets.
368fn diff_content<'a>(
369    diff: &'a DiffInfo,
370    c: &ThemeColors,
371    scroll_offset: f32,
372) -> Element<'a, Message> {
373    let file_path_display = diff.display_path().to_string();
374
375    let status_color = theme::status_color(&diff.status, c);
376
377    let status_badge = text(format!(" {} ", diff.status))
378        .size(12)
379        .color(status_color)
380        .font(Font::MONOSPACE);
381
382    let file_label = text(file_path_display)
383        .size(14)
384        .color(c.text_primary)
385        .font(Font::MONOSPACE);
386
387    let file_header = container(
388        row![status_badge, Space::new().width(8), file_label].align_y(iced::Alignment::Center),
389    )
390    .padding([8, 12])
391    .width(Length::Fill)
392    .style(theme::header_style);
393
394    let mut lines_col = column![].width(Length::Fill);
395
396    if diff.hunks.is_empty() {
397        let empty_msg = text("No diff content available.")
398            .size(13)
399            .color(c.muted)
400            .font(Font::MONOSPACE);
401        lines_col = lines_col.push(container(empty_msg).padding([8, 12]));
402    } else {
403        // Flatten all lines for virtual scrolling
404        let total_lines: usize = diff.hunks.iter().map(|h| h.lines.len()).sum();
405
406        let first = ((scroll_offset / DIFF_LINE_HEIGHT) as usize).saturating_sub(DIFF_OVERSCAN);
407        let last = (first + DIFF_VISIBLE_LINES + 2 * DIFF_OVERSCAN).min(total_lines);
408
409        let top_space = first as f32 * DIFF_LINE_HEIGHT;
410        let bottom_space = (total_lines - last) as f32 * DIFF_LINE_HEIGHT;
411
412        if top_space > 0.0 {
413            lines_col = lines_col.push(Space::new().height(top_space));
414        }
415
416        // Iterate through hunks to find lines in range [first..last)
417        let mut global_idx = 0usize;
418        for hunk in &diff.hunks {
419            for line in &hunk.lines {
420                if global_idx >= first && global_idx < last {
421                    lines_col = lines_col.push(render_line(line, c));
422                }
423                global_idx += 1;
424                if global_idx >= last {
425                    break;
426                }
427            }
428            if global_idx >= last {
429                break;
430            }
431        }
432
433        if bottom_space > 0.0 {
434            lines_col = lines_col.push(Space::new().height(bottom_space));
435        }
436    }
437
438    let scrollable_content = scrollable(lines_col)
439        .height(Length::Fill)
440        .on_scroll(|vp| Message::DiffViewScrolled(vp.absolute_offset().y))
441        .direction(view_utils::thin_scrollbar())
442        .style(crate::theme::overlay_scrollbar);
443
444    column![file_header, scrollable_content]
445        .width(Length::Fill)
446        .height(Length::Fill)
447        .into()
448}
449
450/// Build a single diff line widget: prefix + content, monospace, colored, with optional background.
451fn diff_line_widget<'a>(
452    prefix: &'static str,
453    content_str: &str,
454    color: iced::Color,
455    style: Option<fn(&iced::Theme) -> iced::widget::container::Style>,
456) -> Element<'a, Message> {
457    let prefix_w = text(prefix).size(13).font(Font::MONOSPACE).color(color);
458    let content = text(content_str.to_string())
459        .size(13)
460        .font(Font::MONOSPACE)
461        .color(color);
462    let c = container(row![prefix_w, Space::new().width(4), content])
463        .padding([1, 12])
464        .width(Length::Fill);
465    match style {
466        Some(s) => c.style(s).into(),
467        None => c.into(),
468    }
469}
470
471/// Render a single [`DiffLine`] as a styled container with monospace text.
472fn render_line<'a>(line: &DiffLine, c: &ThemeColors) -> Element<'a, Message> {
473    match line {
474        DiffLine::HunkHeader(header) => {
475            let content = text(header.clone())
476                .size(13)
477                .font(Font::MONOSPACE)
478                .color(c.accent);
479            container(content)
480                .padding([4, 12])
481                .width(Length::Fill)
482                .style(theme::diff_hunk_style)
483                .into()
484        }
485        DiffLine::Addition(s) => diff_line_widget("+", s, c.green, Some(theme::diff_add_style)),
486        DiffLine::Deletion(s) => diff_line_widget("-", s, c.red, Some(theme::diff_del_style)),
487        DiffLine::Context(s) => diff_line_widget(" ", s, c.text_secondary, None),
488    }
489}
490
491// ── File history overlay ──────────────────────────────────────────────────────
492
493/// Render the file-history overlay in the diff panel area.
494pub fn file_history_view(state: &GitKraft) -> Element<'_, Message> {
495    let tab = state.active_tab();
496    let c = state.colors();
497    let path = tab.file_history_path.as_deref().unwrap_or("");
498    let file_name = path.rsplit('/').next().unwrap_or(path);
499
500    let close_btn = button(text("✕").size(13).color(c.muted))
501        .padding([2, 8])
502        .style(theme::ghost_button)
503        .on_press(Message::CloseFileHistory);
504
505    let header = row![
506        icon!(icons::CLOCK, 14, c.accent),
507        Space::new().width(6),
508        text(format!("File History: {file_name}"))
509            .size(14)
510            .color(c.text_primary),
511        Space::new().width(Length::Fill),
512        close_btn,
513    ]
514    .align_y(Alignment::Center)
515    .padding([8, 10]);
516
517    let body: Element<'_, Message> = if tab.file_history_commits.is_empty() {
518        let msg = if tab.is_loading {
519            "Loading…"
520        } else {
521            "No commits touch this file."
522        };
523        container(text(msg).size(13).color(c.muted))
524            .width(Length::Fill)
525            .padding(20)
526            .center_x(Length::Fill)
527            .into()
528    } else {
529        let mut list = column![].width(Length::Fill);
530        for commit in &tab.file_history_commits {
531            let short = commit.short_oid.as_str();
532            let summary = view_utils::truncate_to_fit(&commit.summary, 280.0, 7.0);
533            let rel_time = commit.relative_time();
534
535            let row_content = row![
536                text(short).size(11).color(c.accent).font(Font::MONOSPACE),
537                Space::new().width(8),
538                container(
539                    text(summary)
540                        .size(12)
541                        .color(c.text_primary)
542                        .wrapping(iced::widget::text::Wrapping::None),
543                )
544                .width(Length::Fill)
545                .clip(true),
546                Space::new().width(8),
547                text(rel_time).size(11).color(c.muted),
548            ]
549            .align_y(Alignment::Center)
550            .padding([3, 8]);
551
552            let oid = commit.oid.clone();
553            list = list.push(
554                button(row_content)
555                    .padding(0)
556                    .width(Length::Fill)
557                    .style(theme::ghost_button)
558                    .on_press(Message::SelectFileHistoryCommit(oid)),
559            );
560        }
561
562        scrollable(list)
563            .height(Length::Fill)
564            .on_scroll(|vp| Message::FileHistoryScrolled(vp.absolute_offset().y))
565            .direction(view_utils::thin_scrollbar())
566            .style(theme::overlay_scrollbar)
567            .into()
568    };
569
570    let content = column![header, body]
571        .width(Length::Fill)
572        .height(Length::Fill);
573
574    view_utils::surface_panel(content, Length::Fill)
575}
576
577// ── Blame overlay ─────────────────────────────────────────────────────────────
578
579/// Render the blame overlay in the diff panel area.
580pub fn blame_view(state: &GitKraft) -> Element<'_, Message> {
581    let tab = state.active_tab();
582    let c = state.colors();
583    let path = tab.blame_path.as_deref().unwrap_or("");
584    let file_name = path.rsplit('/').next().unwrap_or(path);
585
586    let close_btn = button(
587        row![
588            text("✕").size(12).color(c.text_primary),
589            Space::new().width(4),
590            text("Close").size(12).color(c.text_primary),
591            Space::new().width(4),
592            text("[Esc]").size(11).color(c.muted),
593        ]
594        .align_y(Alignment::Center),
595    )
596    .padding([4, 10])
597    .style(theme::toolbar_button)
598    .on_press(Message::CloseFileBlame);
599
600    let header = row![
601        icon!(icons::CLOCK, 14, c.accent),
602        Space::new().width(6),
603        text(format!("Blame: {file_name}"))
604            .size(14)
605            .color(c.text_primary),
606        Space::new().width(Length::Fill),
607        close_btn,
608    ]
609    .align_y(Alignment::Center)
610    .padding([8, 10]);
611
612    let body: Element<'_, Message> = if tab.blame_lines.is_empty() {
613        let msg = if tab.is_loading {
614            "Loading…"
615        } else {
616            "No blame data."
617        };
618        container(text(msg).size(13).color(c.muted))
619            .width(Length::Fill)
620            .padding(20)
621            .center_x(Length::Fill)
622            .into()
623    } else {
624        let mut list = column![].width(Length::Fill);
625        for line in &tab.blame_lines {
626            let rel_time = line.relative_time();
627            let author = view_utils::truncate_to_fit(&line.author_name, 80.0, 7.0);
628            let line_content = container(
629                text(line.content.as_str())
630                    .size(11)
631                    .color(c.text_primary)
632                    .font(Font::MONOSPACE)
633                    .wrapping(iced::widget::text::Wrapping::None),
634            )
635            .width(Length::Fill)
636            .clip(true);
637
638            let blame_row = row![
639                text(line.short_oid.as_str())
640                    .size(10)
641                    .color(c.accent)
642                    .font(Font::MONOSPACE),
643                Space::new().width(6),
644                container(
645                    text(author)
646                        .size(10)
647                        .color(c.text_secondary)
648                        .wrapping(iced::widget::text::Wrapping::None),
649                )
650                .width(80)
651                .clip(true),
652                Space::new().width(4),
653                container(
654                    text(rel_time)
655                        .size(10)
656                        .color(c.muted)
657                        .wrapping(iced::widget::text::Wrapping::None),
658                )
659                .width(54)
660                .clip(true),
661                Space::new().width(4),
662                container(
663                    text(format!("{:4}", line.line_number))
664                        .size(10)
665                        .color(c.muted)
666                        .font(Font::MONOSPACE),
667                )
668                .width(32),
669                Space::new().width(6),
670                line_content,
671            ]
672            .align_y(Alignment::Center)
673            .padding([1, 8]);
674
675            list = list.push(blame_row);
676        }
677
678        scrollable(list)
679            .height(Length::Fill)
680            .on_scroll(|vp| Message::BlameScrolled(vp.absolute_offset().y))
681            .direction(view_utils::thin_scrollbar())
682            .style(theme::overlay_scrollbar)
683            .into()
684    };
685
686    let content = column![header, body]
687        .width(Length::Fill)
688        .height(Length::Fill);
689
690    view_utils::surface_panel(content, Length::Fill)
691}