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 = row_style!(
282            is_selected => theme::selected_row_style,
283            is_multi_selected => theme::highlight_row_style,
284        );
285
286        let file_btn = button(row_content)
287            .padding(0)
288            .width(Length::Fill)
289            .style(theme::ghost_button)
290            .on_press(Message::SelectDiffByIndex(idx));
291
292        let file_row = mouse_area(
293            container(file_btn)
294                .width(Length::Fill)
295                .height(Length::Fixed(26.0))
296                .clip(true)
297                .style(style_fn),
298        )
299        .on_right_press(Message::OpenCommitFileContextMenu(
300            oid_for_menu.clone(),
301            diff.display_path().to_string(),
302        ));
303
304        file_list_col = file_list_col.push(file_row);
305    }
306
307    let scrollable_files = scrollable(file_list_col)
308        .height(Length::Fill)
309        .direction(view_utils::thin_scrollbar())
310        .style(crate::theme::overlay_scrollbar);
311
312    container(
313        column![header_row, scrollable_files]
314            .width(Length::Fill)
315            .height(Length::Fill),
316    )
317    .width(Length::Fixed(width))
318    .height(Length::Fill)
319    .style(theme::sidebar_style)
320    .into()
321}
322
323/// Render concatenated diffs for multiple selected files, separated by per-file
324/// headers showing the file path and status.
325fn multi_diff_content<'a>(
326    diffs: &'a [DiffInfo],
327    c: &ThemeColors,
328    scroll_offset: f32,
329) -> Element<'a, Message> {
330    // Count total lines across all files for virtual scrolling.
331    // Per file: 1 header + content lines (or 1 "no content" line) + 1 gap
332    let total_lines: usize = diffs
333        .iter()
334        .map(|d| {
335            let content = if d.hunks.is_empty() {
336                1 // "No diff content." placeholder
337            } else {
338                d.hunks.iter().map(|h| h.lines.len()).sum::<usize>()
339            };
340            1 + content + 1 // header + content + gap
341        })
342        .sum();
343
344    let first = ((scroll_offset / DIFF_LINE_HEIGHT) as usize).saturating_sub(DIFF_OVERSCAN);
345    let last = (first + DIFF_VISIBLE_LINES + 2 * DIFF_OVERSCAN).min(total_lines);
346
347    let top_space = first as f32 * DIFF_LINE_HEIGHT;
348    let bottom_space = (total_lines.saturating_sub(last)) as f32 * DIFF_LINE_HEIGHT;
349
350    let mut col = column![].width(Length::Fill);
351
352    if top_space > 0.0 {
353        col = col.push(Space::new().height(top_space));
354    }
355
356    let mut global_idx = 0usize;
357    for diff in diffs {
358        // Per-file header bar (counts as one "line" for virtual scrolling)
359        if global_idx >= first && global_idx < last {
360            let status_color = theme::status_color(&diff.status, c);
361            let header = container(
362                row![
363                    text(format!(" {} ", diff.status))
364                        .size(12)
365                        .color(status_color)
366                        .font(Font::MONOSPACE),
367                    Space::new().width(8),
368                    text(diff.display_path().to_string())
369                        .size(13)
370                        .color(c.text_primary)
371                        .font(Font::MONOSPACE),
372                ]
373                .align_y(Alignment::Center),
374            )
375            .padding([6, 12])
376            .width(Length::Fill)
377            .style(theme::header_style);
378            col = col.push(header);
379        }
380        global_idx += 1;
381        if global_idx >= last {
382            break;
383        }
384
385        if diff.hunks.is_empty() {
386            if global_idx >= first && global_idx < last {
387                col = col.push(
388                    container(
389                        text("No diff content.")
390                            .size(13)
391                            .color(c.muted)
392                            .font(Font::MONOSPACE),
393                    )
394                    .padding([4, 12]),
395                );
396            }
397            global_idx += 1;
398        } else {
399            for hunk in &diff.hunks {
400                for line in &hunk.lines {
401                    if global_idx >= first && global_idx < last {
402                        col = col.push(render_line(line, c));
403                    }
404                    global_idx += 1;
405                    if global_idx >= last {
406                        break;
407                    }
408                }
409                if global_idx >= last {
410                    break;
411                }
412            }
413        }
414
415        // Small gap between files
416        if global_idx >= first && global_idx < last {
417            col = col.push(Space::new().height(8));
418        }
419        global_idx += 1;
420        if global_idx >= last {
421            break;
422        }
423    }
424
425    if bottom_space > 0.0 {
426        col = col.push(Space::new().height(bottom_space));
427    }
428
429    scrollable(col)
430        .height(Length::Fill)
431        .on_scroll(|vp| Message::DiffViewScrolled(vp.absolute_offset().y))
432        .direction(view_utils::thin_scrollbar())
433        .style(crate::theme::overlay_scrollbar)
434        .into()
435}
436
437/// Placeholder shown when no diff is selected.
438fn placeholder_view<'a>(c: &ThemeColors) -> Element<'a, Message> {
439    view_utils::centered_placeholder(
440        icons::FILE_DIFF,
441        32,
442        "Select a commit or file to view diff",
443        c.muted,
444    )
445}
446
447/// Loading indicator shown while a single file's diff is being fetched.
448fn loading_diff_view<'a>(c: &ThemeColors, animation_tick: u64) -> Element<'a, Message> {
449    let frames = tui_spinner::FluxFrames::CORNERS;
450    let frame = frames[animation_tick as usize % frames.len()].to_string();
451    let label = format!("{frame}  Loading diff…");
452    container(text(label).size(14).color(c.muted).font(Font::MONOSPACE))
453        .width(Length::Fill)
454        .height(Length::Fill)
455        .center_x(Length::Fill)
456        .center_y(Length::Fill)
457        .into()
458}
459
460/// Render the full diff content for a single [`DiffInfo`] with virtual
461/// scrolling. Only lines within (or near) the visible viewport are
462/// materialised as widgets.
463fn diff_content<'a>(
464    diff: &'a DiffInfo,
465    c: &ThemeColors,
466    scroll_offset: f32,
467) -> Element<'a, Message> {
468    let file_path_display = diff.display_path().to_string();
469
470    let status_color = theme::status_color(&diff.status, c);
471
472    let status_badge = text(format!(" {} ", diff.status))
473        .size(12)
474        .color(status_color)
475        .font(Font::MONOSPACE);
476
477    let file_label = text(file_path_display)
478        .size(14)
479        .color(c.text_primary)
480        .font(Font::MONOSPACE);
481
482    let file_header = container(
483        row![status_badge, Space::new().width(8), file_label].align_y(iced::Alignment::Center),
484    )
485    .padding([8, 12])
486    .width(Length::Fill)
487    .style(theme::header_style);
488
489    let mut lines_col = column![].width(Length::Fill);
490
491    if diff.hunks.is_empty() {
492        let empty_msg = text("No diff content available.")
493            .size(13)
494            .color(c.muted)
495            .font(Font::MONOSPACE);
496        lines_col = lines_col.push(container(empty_msg).padding([8, 12]));
497    } else {
498        // Flatten all lines for virtual scrolling
499        let total_lines: usize = diff.hunks.iter().map(|h| h.lines.len()).sum();
500
501        let first = ((scroll_offset / DIFF_LINE_HEIGHT) as usize).saturating_sub(DIFF_OVERSCAN);
502        let last = (first + DIFF_VISIBLE_LINES + 2 * DIFF_OVERSCAN).min(total_lines);
503
504        let top_space = first as f32 * DIFF_LINE_HEIGHT;
505        let bottom_space = (total_lines - last) as f32 * DIFF_LINE_HEIGHT;
506
507        if top_space > 0.0 {
508            lines_col = lines_col.push(Space::new().height(top_space));
509        }
510
511        // Iterate through hunks to find lines in range [first..last)
512        let mut global_idx = 0usize;
513        for hunk in &diff.hunks {
514            for line in &hunk.lines {
515                if global_idx >= first && global_idx < last {
516                    lines_col = lines_col.push(render_line(line, c));
517                }
518                global_idx += 1;
519                if global_idx >= last {
520                    break;
521                }
522            }
523            if global_idx >= last {
524                break;
525            }
526        }
527
528        if bottom_space > 0.0 {
529            lines_col = lines_col.push(Space::new().height(bottom_space));
530        }
531    }
532
533    let scrollable_content = scrollable(lines_col)
534        .height(Length::Fill)
535        .on_scroll(|vp| Message::DiffViewScrolled(vp.absolute_offset().y))
536        .direction(view_utils::thin_scrollbar())
537        .style(crate::theme::overlay_scrollbar);
538
539    column![file_header, scrollable_content]
540        .width(Length::Fill)
541        .height(Length::Fill)
542        .into()
543}
544
545/// Build a single diff line widget: prefix + content, monospace, colored, with optional background.
546fn diff_line_widget<'a>(
547    prefix: &'static str,
548    content_str: &str,
549    color: iced::Color,
550    style: Option<fn(&iced::Theme) -> iced::widget::container::Style>,
551) -> Element<'a, Message> {
552    let prefix_w = text(prefix).size(13).font(Font::MONOSPACE).color(color);
553    let content = text(content_str.to_string())
554        .size(13)
555        .font(Font::MONOSPACE)
556        .color(color);
557    let c = container(row![prefix_w, Space::new().width(4), content])
558        .padding([1, 12])
559        .width(Length::Fill);
560    match style {
561        Some(s) => c.style(s).into(),
562        None => c.into(),
563    }
564}
565
566/// Render a single [`DiffLine`] as a styled container with monospace text.
567fn render_line<'a>(line: &DiffLine, c: &ThemeColors) -> Element<'a, Message> {
568    match line {
569        DiffLine::HunkHeader(header) => {
570            let content = text(header.clone())
571                .size(13)
572                .font(Font::MONOSPACE)
573                .color(c.accent);
574            container(content)
575                .padding([4, 12])
576                .width(Length::Fill)
577                .style(theme::diff_hunk_style)
578                .into()
579        }
580        DiffLine::Addition(s) => diff_line_widget("+", s, c.green, Some(theme::diff_add_style)),
581        DiffLine::Deletion(s) => diff_line_widget("-", s, c.red, Some(theme::diff_del_style)),
582        DiffLine::Context(s) => diff_line_widget(" ", s, c.diff_context, None),
583    }
584}
585
586// ── File history overlay ──────────────────────────────────────────────────────
587
588/// Render the file-history overlay in the diff panel area.
589pub(crate) fn file_history_view(state: &GitKraft) -> Element<'_, Message> {
590    let tab = state.active_tab();
591    let c = state.colors();
592    let path = tab.file_history_path.as_deref().unwrap_or("");
593    let file_name = gitkraft_core::path_basename(path);
594
595    let close_btn = button(text("✕").size(13).color(c.muted))
596        .padding([2, 8])
597        .style(theme::ghost_button)
598        .on_press(Message::CloseFileHistory);
599
600    let header = row![
601        icon!(icons::CLOCK, 14, c.accent),
602        Space::new().width(6),
603        text(format!("File History: {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.file_history_commits.is_empty() {
613        let msg = if tab.is_loading {
614            "Loading…"
615        } else {
616            "No commits touch this file."
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 commit in &tab.file_history_commits {
626            let short = commit.short_oid();
627            let summary = view_utils::truncate_to_fit(&commit.summary, 280.0, 7.0);
628            let rel_time = commit.relative_time();
629
630            let row_content = row![
631                text(short).size(11).color(c.accent).font(Font::MONOSPACE),
632                Space::new().width(8),
633                container(
634                    text(summary)
635                        .size(12)
636                        .color(c.text_primary)
637                        .wrapping(iced::widget::text::Wrapping::None),
638                )
639                .width(Length::Fill)
640                .clip(true),
641                Space::new().width(8),
642                text(rel_time).size(11).color(c.muted),
643            ]
644            .align_y(Alignment::Center)
645            .padding([3, 8]);
646
647            let oid = commit.oid.clone();
648            list = list.push(
649                button(row_content)
650                    .padding(0)
651                    .width(Length::Fill)
652                    .style(theme::ghost_button)
653                    .on_press(Message::SelectFileHistoryCommit(oid)),
654            );
655        }
656
657        scrollable(list)
658            .height(Length::Fill)
659            .on_scroll(|vp| Message::FileHistoryScrolled(vp.absolute_offset().y))
660            .direction(view_utils::thin_scrollbar())
661            .style(theme::overlay_scrollbar)
662            .into()
663    };
664
665    let content = column![header, body]
666        .width(Length::Fill)
667        .height(Length::Fill);
668
669    view_utils::surface_panel(content, Length::Fill)
670}
671
672// ── Blame overlay ─────────────────────────────────────────────────────────────
673
674/// Render the blame overlay in the diff panel area.
675pub(crate) fn blame_view(state: &GitKraft) -> Element<'_, Message> {
676    let tab = state.active_tab();
677    let c = state.colors();
678    let path = tab.blame_path.as_deref().unwrap_or("");
679    let file_name = gitkraft_core::path_basename(path);
680
681    let close_btn = button(
682        row![
683            text("✕").size(12).color(c.text_primary),
684            Space::new().width(4),
685            text("Close").size(12).color(c.text_primary),
686            Space::new().width(4),
687            text("[Esc]").size(11).color(c.muted),
688        ]
689        .align_y(Alignment::Center),
690    )
691    .padding([4, 10])
692    .style(theme::toolbar_button)
693    .on_press(Message::CloseFileBlame);
694
695    let header = row![
696        icon!(icons::CLOCK, 14, c.accent),
697        Space::new().width(6),
698        text(format!("Blame: {file_name}"))
699            .size(14)
700            .color(c.text_primary),
701        Space::new().width(Length::Fill),
702        close_btn,
703    ]
704    .align_y(Alignment::Center)
705    .padding([8, 10]);
706
707    let body: Element<'_, Message> = if tab.blame_lines.is_empty() {
708        let msg = if tab.is_loading {
709            "Loading…"
710        } else {
711            "No blame data."
712        };
713        container(text(msg).size(13).color(c.muted))
714            .width(Length::Fill)
715            .padding(20)
716            .center_x(Length::Fill)
717            .into()
718    } else {
719        let mut list = column![].width(Length::Fill);
720        for line in &tab.blame_lines {
721            let rel_time = line.relative_time();
722            let author = view_utils::truncate_to_fit(&line.author_name, 80.0, 7.0);
723            let line_content = container(
724                text(line.content.as_str())
725                    .size(11)
726                    .color(c.text_primary)
727                    .font(Font::MONOSPACE)
728                    .wrapping(iced::widget::text::Wrapping::None),
729            )
730            .width(Length::Fill)
731            .clip(true);
732
733            let blame_row = row![
734                text(line.short_oid())
735                    .size(10)
736                    .color(c.accent)
737                    .font(Font::MONOSPACE),
738                Space::new().width(6),
739                container(
740                    text(author)
741                        .size(10)
742                        .color(c.text_secondary)
743                        .wrapping(iced::widget::text::Wrapping::None),
744                )
745                .width(80)
746                .clip(true),
747                Space::new().width(4),
748                container(
749                    text(rel_time)
750                        .size(10)
751                        .color(c.muted)
752                        .wrapping(iced::widget::text::Wrapping::None),
753                )
754                .width(54)
755                .clip(true),
756                Space::new().width(4),
757                container(
758                    text(format!("{:4}", line.line_number))
759                        .size(10)
760                        .color(c.muted)
761                        .font(Font::MONOSPACE),
762                )
763                .width(32),
764                Space::new().width(6),
765                line_content,
766            ]
767            .align_y(Alignment::Center)
768            .padding([1, 8]);
769
770            list = list.push(blame_row);
771        }
772
773        scrollable(list)
774            .height(Length::Fill)
775            .on_scroll(|vp| Message::BlameScrolled(vp.absolute_offset().y))
776            .direction(view_utils::thin_scrollbar())
777            .style(theme::overlay_scrollbar)
778            .into()
779    };
780
781    let content = column![header, body]
782        .width(Length::Fill)
783        .height(Length::Fill);
784
785    view_utils::surface_panel(content, Length::Fill)
786}