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, state.animation_tick))
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, state.animation_tick)
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, animation_tick: u64) -> Element<'a, Message> {
362    let frames = tui_spinner::FluxFrames::CORNERS;
363    let frame = frames[animation_tick as usize % frames.len()].to_string();
364    let label = format!("{frame}  Loading diff…");
365    container(text(label).size(14).color(c.muted).font(Font::MONOSPACE))
366        .width(Length::Fill)
367        .height(Length::Fill)
368        .center_x(Length::Fill)
369        .center_y(Length::Fill)
370        .into()
371}
372
373/// Render the full diff content for a single [`DiffInfo`] with virtual
374/// scrolling. Only lines within (or near) the visible viewport are
375/// materialised as widgets.
376fn diff_content<'a>(
377    diff: &'a DiffInfo,
378    c: &ThemeColors,
379    scroll_offset: f32,
380) -> Element<'a, Message> {
381    let file_path_display = diff.display_path().to_string();
382
383    let status_color = theme::status_color(&diff.status, c);
384
385    let status_badge = text(format!(" {} ", diff.status))
386        .size(12)
387        .color(status_color)
388        .font(Font::MONOSPACE);
389
390    let file_label = text(file_path_display)
391        .size(14)
392        .color(c.text_primary)
393        .font(Font::MONOSPACE);
394
395    let file_header = container(
396        row![status_badge, Space::new().width(8), file_label].align_y(iced::Alignment::Center),
397    )
398    .padding([8, 12])
399    .width(Length::Fill)
400    .style(theme::header_style);
401
402    let mut lines_col = column![].width(Length::Fill);
403
404    if diff.hunks.is_empty() {
405        let empty_msg = text("No diff content available.")
406            .size(13)
407            .color(c.muted)
408            .font(Font::MONOSPACE);
409        lines_col = lines_col.push(container(empty_msg).padding([8, 12]));
410    } else {
411        // Flatten all lines for virtual scrolling
412        let total_lines: usize = diff.hunks.iter().map(|h| h.lines.len()).sum();
413
414        let first = ((scroll_offset / DIFF_LINE_HEIGHT) as usize).saturating_sub(DIFF_OVERSCAN);
415        let last = (first + DIFF_VISIBLE_LINES + 2 * DIFF_OVERSCAN).min(total_lines);
416
417        let top_space = first as f32 * DIFF_LINE_HEIGHT;
418        let bottom_space = (total_lines - last) as f32 * DIFF_LINE_HEIGHT;
419
420        if top_space > 0.0 {
421            lines_col = lines_col.push(Space::new().height(top_space));
422        }
423
424        // Iterate through hunks to find lines in range [first..last)
425        let mut global_idx = 0usize;
426        for hunk in &diff.hunks {
427            for line in &hunk.lines {
428                if global_idx >= first && global_idx < last {
429                    lines_col = lines_col.push(render_line(line, c));
430                }
431                global_idx += 1;
432                if global_idx >= last {
433                    break;
434                }
435            }
436            if global_idx >= last {
437                break;
438            }
439        }
440
441        if bottom_space > 0.0 {
442            lines_col = lines_col.push(Space::new().height(bottom_space));
443        }
444    }
445
446    let scrollable_content = scrollable(lines_col)
447        .height(Length::Fill)
448        .on_scroll(|vp| Message::DiffViewScrolled(vp.absolute_offset().y))
449        .direction(view_utils::thin_scrollbar())
450        .style(crate::theme::overlay_scrollbar);
451
452    column![file_header, scrollable_content]
453        .width(Length::Fill)
454        .height(Length::Fill)
455        .into()
456}
457
458/// Build a single diff line widget: prefix + content, monospace, colored, with optional background.
459fn diff_line_widget<'a>(
460    prefix: &'static str,
461    content_str: &str,
462    color: iced::Color,
463    style: Option<fn(&iced::Theme) -> iced::widget::container::Style>,
464) -> Element<'a, Message> {
465    let prefix_w = text(prefix).size(13).font(Font::MONOSPACE).color(color);
466    let content = text(content_str.to_string())
467        .size(13)
468        .font(Font::MONOSPACE)
469        .color(color);
470    let c = container(row![prefix_w, Space::new().width(4), content])
471        .padding([1, 12])
472        .width(Length::Fill);
473    match style {
474        Some(s) => c.style(s).into(),
475        None => c.into(),
476    }
477}
478
479/// Render a single [`DiffLine`] as a styled container with monospace text.
480fn render_line<'a>(line: &DiffLine, c: &ThemeColors) -> Element<'a, Message> {
481    match line {
482        DiffLine::HunkHeader(header) => {
483            let content = text(header.clone())
484                .size(13)
485                .font(Font::MONOSPACE)
486                .color(c.accent);
487            container(content)
488                .padding([4, 12])
489                .width(Length::Fill)
490                .style(theme::diff_hunk_style)
491                .into()
492        }
493        DiffLine::Addition(s) => diff_line_widget("+", s, c.green, Some(theme::diff_add_style)),
494        DiffLine::Deletion(s) => diff_line_widget("-", s, c.red, Some(theme::diff_del_style)),
495        DiffLine::Context(s) => diff_line_widget(" ", s, c.text_secondary, None),
496    }
497}
498
499// ── File history overlay ──────────────────────────────────────────────────────
500
501/// Render the file-history overlay in the diff panel area.
502pub fn file_history_view(state: &GitKraft) -> Element<'_, Message> {
503    let tab = state.active_tab();
504    let c = state.colors();
505    let path = tab.file_history_path.as_deref().unwrap_or("");
506    let file_name = path.rsplit('/').next().unwrap_or(path);
507
508    let close_btn = button(text("✕").size(13).color(c.muted))
509        .padding([2, 8])
510        .style(theme::ghost_button)
511        .on_press(Message::CloseFileHistory);
512
513    let header = row![
514        icon!(icons::CLOCK, 14, c.accent),
515        Space::new().width(6),
516        text(format!("File History: {file_name}"))
517            .size(14)
518            .color(c.text_primary),
519        Space::new().width(Length::Fill),
520        close_btn,
521    ]
522    .align_y(Alignment::Center)
523    .padding([8, 10]);
524
525    let body: Element<'_, Message> = if tab.file_history_commits.is_empty() {
526        let msg = if tab.is_loading {
527            "Loading…"
528        } else {
529            "No commits touch this file."
530        };
531        container(text(msg).size(13).color(c.muted))
532            .width(Length::Fill)
533            .padding(20)
534            .center_x(Length::Fill)
535            .into()
536    } else {
537        let mut list = column![].width(Length::Fill);
538        for commit in &tab.file_history_commits {
539            let short = commit.short_oid.as_str();
540            let summary = view_utils::truncate_to_fit(&commit.summary, 280.0, 7.0);
541            let rel_time = commit.relative_time();
542
543            let row_content = row![
544                text(short).size(11).color(c.accent).font(Font::MONOSPACE),
545                Space::new().width(8),
546                container(
547                    text(summary)
548                        .size(12)
549                        .color(c.text_primary)
550                        .wrapping(iced::widget::text::Wrapping::None),
551                )
552                .width(Length::Fill)
553                .clip(true),
554                Space::new().width(8),
555                text(rel_time).size(11).color(c.muted),
556            ]
557            .align_y(Alignment::Center)
558            .padding([3, 8]);
559
560            let oid = commit.oid.clone();
561            list = list.push(
562                button(row_content)
563                    .padding(0)
564                    .width(Length::Fill)
565                    .style(theme::ghost_button)
566                    .on_press(Message::SelectFileHistoryCommit(oid)),
567            );
568        }
569
570        scrollable(list)
571            .height(Length::Fill)
572            .on_scroll(|vp| Message::FileHistoryScrolled(vp.absolute_offset().y))
573            .direction(view_utils::thin_scrollbar())
574            .style(theme::overlay_scrollbar)
575            .into()
576    };
577
578    let content = column![header, body]
579        .width(Length::Fill)
580        .height(Length::Fill);
581
582    view_utils::surface_panel(content, Length::Fill)
583}
584
585// ── Blame overlay ─────────────────────────────────────────────────────────────
586
587/// Render the blame overlay in the diff panel area.
588pub fn blame_view(state: &GitKraft) -> Element<'_, Message> {
589    let tab = state.active_tab();
590    let c = state.colors();
591    let path = tab.blame_path.as_deref().unwrap_or("");
592    let file_name = path.rsplit('/').next().unwrap_or(path);
593
594    let close_btn = button(
595        row![
596            text("✕").size(12).color(c.text_primary),
597            Space::new().width(4),
598            text("Close").size(12).color(c.text_primary),
599            Space::new().width(4),
600            text("[Esc]").size(11).color(c.muted),
601        ]
602        .align_y(Alignment::Center),
603    )
604    .padding([4, 10])
605    .style(theme::toolbar_button)
606    .on_press(Message::CloseFileBlame);
607
608    let header = row![
609        icon!(icons::CLOCK, 14, c.accent),
610        Space::new().width(6),
611        text(format!("Blame: {file_name}"))
612            .size(14)
613            .color(c.text_primary),
614        Space::new().width(Length::Fill),
615        close_btn,
616    ]
617    .align_y(Alignment::Center)
618    .padding([8, 10]);
619
620    let body: Element<'_, Message> = if tab.blame_lines.is_empty() {
621        let msg = if tab.is_loading {
622            "Loading…"
623        } else {
624            "No blame data."
625        };
626        container(text(msg).size(13).color(c.muted))
627            .width(Length::Fill)
628            .padding(20)
629            .center_x(Length::Fill)
630            .into()
631    } else {
632        let mut list = column![].width(Length::Fill);
633        for line in &tab.blame_lines {
634            let rel_time = line.relative_time();
635            let author = view_utils::truncate_to_fit(&line.author_name, 80.0, 7.0);
636            let line_content = container(
637                text(line.content.as_str())
638                    .size(11)
639                    .color(c.text_primary)
640                    .font(Font::MONOSPACE)
641                    .wrapping(iced::widget::text::Wrapping::None),
642            )
643            .width(Length::Fill)
644            .clip(true);
645
646            let blame_row = row![
647                text(line.short_oid.as_str())
648                    .size(10)
649                    .color(c.accent)
650                    .font(Font::MONOSPACE),
651                Space::new().width(6),
652                container(
653                    text(author)
654                        .size(10)
655                        .color(c.text_secondary)
656                        .wrapping(iced::widget::text::Wrapping::None),
657                )
658                .width(80)
659                .clip(true),
660                Space::new().width(4),
661                container(
662                    text(rel_time)
663                        .size(10)
664                        .color(c.muted)
665                        .wrapping(iced::widget::text::Wrapping::None),
666                )
667                .width(54)
668                .clip(true),
669                Space::new().width(4),
670                container(
671                    text(format!("{:4}", line.line_number))
672                        .size(10)
673                        .color(c.muted)
674                        .font(Font::MONOSPACE),
675                )
676                .width(32),
677                Space::new().width(6),
678                line_content,
679            ]
680            .align_y(Alignment::Center)
681            .padding([1, 8]);
682
683            list = list.push(blame_row);
684        }
685
686        scrollable(list)
687            .height(Length::Fill)
688            .on_scroll(|vp| Message::BlameScrolled(vp.absolute_offset().y))
689            .direction(view_utils::thin_scrollbar())
690            .style(theme::overlay_scrollbar)
691            .into()
692    };
693
694    let content = column![header, body]
695        .width(Length::Fill)
696        .height(Length::Fill);
697
698    view_utils::surface_panel(content, Length::Fill)
699}