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