Skip to main content

gitkraft_gui/features/commits/
view.rs

1//! Commit log view — scrollable list of commits with highlighted selection.
2//!
3//! Commit summaries are pre-truncated with "…" based on the actual available
4//! pixel width so that each row stays on exactly one line — matching
5//! GitKraken's behaviour.
6
7//
8// Renders only the rows currently visible in the viewport plus a small
9// overscan buffer.  Space widgets above and below maintain the correct
10// total scroll height so the scrollbar behaves naturally.
11
12use iced::widget::{button, column, container, mouse_area, row, scrollable, text, Row, Space};
13use iced::{Alignment, Color, Element, Length};
14
15use crate::icons;
16use crate::message::Message;
17use crate::state::{GitKraft, RepoTab};
18use crate::theme;
19use crate::theme::ThemeColors;
20use crate::view_utils;
21use crate::view_utils::truncate_to_fit;
22
23// ── ref-badge helpers ─────────────────────────────────────────────────────
24
25/// Foreground (text + border) colour for a ref badge.
26fn ref_fg(kind: &gitkraft_core::RefKind, c: &ThemeColors) -> iced::Color {
27    match kind {
28        gitkraft_core::RefKind::Head => c.accent,
29        gitkraft_core::RefKind::LocalBranch => c.green,
30        gitkraft_core::RefKind::RemoteBranch => c.yellow,
31        gitkraft_core::RefKind::Tag => c.muted,
32    }
33}
34
35/// Truncate a ref name to at most 22 chars with a trailing ellipsis.
36fn short_ref_name(name: &str) -> String {
37    const MAX: usize = 22;
38    if name.chars().count() <= MAX {
39        name.to_string()
40    } else {
41        let s: String = name.chars().take(MAX - 1).collect();
42        format!("{s}\u{2026}")
43    }
44}
45
46/// Build a row of coloured badge pills for `refs`, capped at `max_badges`.
47/// Returns `None` when the slice is empty so callers can skip the spacing.
48fn ref_badges_row<'a>(
49    refs: &'a [gitkraft_core::RefLabel],
50    c: &ThemeColors,
51    max_badges: usize,
52) -> Option<Element<'a, Message>> {
53    if refs.is_empty() {
54        return None;
55    }
56    let mut items: Vec<Element<'a, Message>> = Vec::new();
57    for (i, rf) in refs.iter().take(max_badges).enumerate() {
58        let fg = ref_fg(&rf.kind, c);
59        let bg = c.surface;
60        let name = short_ref_name(&rf.name);
61        let badge = container(text(name).size(10).color(fg).font(iced::Font::MONOSPACE))
62            .padding([1, 5])
63            .style(move |_: &iced::Theme| iced::widget::container::Style {
64                background: Some(iced::Background::Color(bg)),
65                border: iced::Border {
66                    color: fg,
67                    width: 1.0,
68                    radius: 3.0.into(),
69                },
70                ..Default::default()
71            });
72        if i > 0 {
73            items.push(Space::new().width(3).into());
74        }
75        items.push(badge.into());
76    }
77    Some(Row::with_children(items).align_y(Alignment::Center).into())
78}
79
80/// Estimated height of one commit row in pixels.  Used for virtual scrolling.
81/// A slight over- or under-estimate only affects scrollbar thumb precision,
82/// not correctness of the rendered content.
83const ROW_HEIGHT: f32 = 26.0;
84
85/// Rows rendered above and below the visible window (avoids pop-in during
86/// fast scrolling).
87const OVERSCAN: usize = 8;
88
89/// Assumed visible rows (covers a 1300 px tall viewport at ROW_HEIGHT).
90/// Making this generous costs almost nothing — we cap at `total` anyway.
91const VISIBLE_ROWS: usize = 50;
92
93/// Per-tab stable scroll id — Iced maintains a separate scroll position for
94/// each open tab so no programmatic `scroll_to` is needed on tab switches.
95pub fn commit_log_scroll_id(tab_index: usize) -> iced::widget::Id {
96    iced::widget::Id::from(format!("commit_log_{tab_index}"))
97}
98
99// ── graph_cell ────────────────────────────────────────────────────────────────
100
101/// Build a small `Row` of individually-coloured text elements representing one
102/// row of the commit graph.
103fn graph_cell<'a>(
104    graph_row: &gitkraft_core::GraphRow,
105    graph_colors: &[Color; 8],
106) -> Row<'a, Message> {
107    let width = graph_row.width;
108    let len = graph_colors.len();
109
110    if width == 0 {
111        return Row::new().push(
112            text("● ")
113                .font(iced::Font::MONOSPACE)
114                .size(12)
115                .color(graph_colors[graph_row.node_color % len]),
116        );
117    }
118
119    let mut column_passthrough: Vec<Option<usize>> = vec![None; width];
120    let mut has_left_cross = false;
121    let mut has_right_cross = false;
122    let mut left_cross_color: usize = 0;
123    let mut right_cross_color: usize = 0;
124    let mut cross_left_col: usize = graph_row.node_column;
125    let mut cross_right_col: usize = graph_row.node_column;
126
127    for edge in &graph_row.edges {
128        if edge.from_column == edge.to_column {
129            column_passthrough[edge.to_column] = Some(edge.color_index);
130        } else {
131            let target = edge.to_column;
132            if target < graph_row.node_column {
133                has_left_cross = true;
134                left_cross_color = edge.color_index;
135                if target < cross_left_col {
136                    cross_left_col = target;
137                }
138            } else if target > graph_row.node_column {
139                has_right_cross = true;
140                right_cross_color = edge.color_index;
141                if target > cross_right_col {
142                    cross_right_col = target;
143                }
144            }
145        }
146    }
147
148    let mut cells: Vec<Element<'a, Message>> = Vec::with_capacity(width);
149
150    for col in 0..width {
151        if col == graph_row.node_column {
152            let color = graph_colors[graph_row.node_color % len];
153            cells.push(
154                text("● ")
155                    .font(iced::Font::MONOSPACE)
156                    .size(12)
157                    .color(color)
158                    .into(),
159            );
160        } else if let Some(ci) = column_passthrough.get(col).copied().flatten() {
161            let in_left = has_left_cross && col >= cross_left_col && col < graph_row.node_column;
162            let in_right = has_right_cross && col > graph_row.node_column && col <= cross_right_col;
163
164            if in_left || in_right {
165                let cross_ci = if in_left {
166                    left_cross_color
167                } else {
168                    right_cross_color
169                };
170                cells.push(
171                    text("├─")
172                        .font(iced::Font::MONOSPACE)
173                        .size(12)
174                        .color(graph_colors[cross_ci % len])
175                        .into(),
176                );
177            } else {
178                cells.push(
179                    text("│ ")
180                        .font(iced::Font::MONOSPACE)
181                        .size(12)
182                        .color(graph_colors[ci % len])
183                        .into(),
184                );
185            }
186        } else {
187            let in_left = has_left_cross && col >= cross_left_col && col < graph_row.node_column;
188            let in_right = has_right_cross && col > graph_row.node_column && col <= cross_right_col;
189
190            if in_left {
191                let color = graph_colors[left_cross_color % len];
192                if col == cross_left_col {
193                    cells.push(
194                        text("╭─")
195                            .font(iced::Font::MONOSPACE)
196                            .size(12)
197                            .color(color)
198                            .into(),
199                    );
200                } else {
201                    cells.push(
202                        text("──")
203                            .font(iced::Font::MONOSPACE)
204                            .size(12)
205                            .color(color)
206                            .into(),
207                    );
208                }
209            } else if in_right {
210                let color = graph_colors[right_cross_color % len];
211                if col == cross_right_col {
212                    cells.push(
213                        text("─╮")
214                            .font(iced::Font::MONOSPACE)
215                            .size(12)
216                            .color(color)
217                            .into(),
218                    );
219                } else {
220                    cells.push(
221                        text("──")
222                            .font(iced::Font::MONOSPACE)
223                            .size(12)
224                            .color(color)
225                            .into(),
226                    );
227                }
228            } else {
229                cells.push(text("  ").font(iced::Font::MONOSPACE).size(12).into());
230            }
231        }
232    }
233
234    Row::with_children(cells).align_y(Alignment::Center)
235}
236
237// ── single row element ────────────────────────────────────────────────────────
238
239/// Build the widget for a single commit row.
240fn commit_row_element<'a>(
241    tab: &'a RepoTab,
242    idx: usize,
243    c: &ThemeColors,
244    available_summary_px: f32,
245    author_width: f32,
246    selected_range: &[usize],
247) -> Element<'a, Message> {
248    let commit = &tab.commits[idx];
249    let is_selected = tab.selected_commit == Some(idx);
250
251    // Badge: position in the selected range (1-based), or blank space
252    let selection_badge: Element<'a, Message> =
253        if let Some(pos) = selected_range.iter().position(|&i| i == idx) {
254            container(
255                text(format!("{}", pos + 1))
256                    .size(10)
257                    .font(iced::Font::MONOSPACE)
258                    .color(c.accent),
259            )
260            .width(16)
261            .center_x(iced::Length::Fixed(16.0))
262            .into()
263        } else {
264            Space::new().width(16).into()
265        };
266
267    // Graph column
268    let graph_elem: Element<'_, Message> = if let Some(grow) = tab.graph_rows.get(idx) {
269        graph_cell(grow, &c.graph_colors).into()
270    } else {
271        text("").into()
272    };
273
274    let oid_label = text(commit.short_oid.as_str())
275        .size(12)
276        .color(c.accent)
277        .font(iced::Font::MONOSPACE);
278
279    // Ref badges (branch / tag / HEAD) shown between the OID and the summary.
280    let badges = ref_badges_row(&commit.refs, c, 3);
281    // Rough pixel budget consumed by the badges so summary truncation stays accurate.
282    let badges_overhead: f32 = if commit.refs.is_empty() {
283        0.0
284    } else {
285        commit
286            .refs
287            .iter()
288            .take(3)
289            .map(|r| r.name.len().min(22) as f32 * 6.5 + 18.0)
290            .sum::<f32>()
291            + 8.0
292    };
293
294    // Use pre-computed display strings; fall back gracefully if out of sync.
295    let (summary_str, time_str, author_str) = tab
296        .commit_display
297        .get(idx)
298        .map(|(s, t, a)| (s.as_str(), t.as_str(), a.as_str()))
299        .unwrap_or((commit.summary.as_str(), "", commit.author_name.as_str()));
300
301    // Pre-truncate with "…" so the full row stays on one line.
302    let row_available = (available_summary_px - badges_overhead).max(40.0);
303    let display_summary = truncate_to_fit(summary_str, row_available, 7.0);
304    let summary_label = container(
305        text(display_summary)
306            .size(12)
307            .color(c.text_primary)
308            .wrapping(iced::widget::text::Wrapping::None),
309    )
310    .width(Length::Fill)
311    .clip(true);
312
313    let author_label = container(
314        text(author_str)
315            .size(11)
316            .color(c.text_secondary)
317            .wrapping(iced::widget::text::Wrapping::None),
318    )
319    .width(author_width)
320    .clip(true);
321
322    let time_label = container(
323        text(time_str)
324            .size(11)
325            .color(c.muted)
326            .wrapping(iced::widget::text::Wrapping::None),
327    )
328    .width(72)
329    .clip(true);
330
331    // Build the row: insert badges between OID and summary when present.
332    let mut row_content = row![
333        selection_badge,
334        Space::new().width(2),
335        graph_elem,
336        oid_label,
337        Space::new().width(6),
338    ]
339    .align_y(Alignment::Center);
340
341    if let Some(b) = badges {
342        row_content = row_content.push(b).push(Space::new().width(4));
343    }
344
345    let row_content = row_content
346        .push(summary_label)
347        .push(Space::new().width(8))
348        .push(author_label)
349        .push(Space::new().width(8))
350        .push(time_label)
351        .padding([3, 8]);
352
353    let is_in_range = selected_range.contains(&idx);
354    let style_fn = if is_selected {
355        theme::selected_row_style as fn(&iced::Theme) -> iced::widget::container::Style
356    } else if is_in_range {
357        theme::highlight_row_style as fn(&iced::Theme) -> iced::widget::container::Style
358    } else {
359        theme::surface_style as fn(&iced::Theme) -> iced::widget::container::Style
360    };
361
362    mouse_area(
363        container(
364            button(row_content)
365                .padding(0)
366                .width(Length::Fill)
367                .on_press(Message::SelectCommit(idx))
368                .style(theme::ghost_button),
369        )
370        .width(Length::Fill)
371        .height(Length::Fixed(ROW_HEIGHT))
372        .clip(true)
373        .style(style_fn),
374    )
375    .on_right_press(Message::OpenCommitContextMenu(idx))
376    .into()
377}
378
379// ── view ─────────────────────────────────────────────────────────────────────
380
381/// Render the commit log panel.
382pub fn view(state: &GitKraft) -> Element<'_, Message> {
383    let tab = state.active_tab();
384    let c = state.colors();
385
386    let header_icon = icon!(icons::CLOCK, 14, c.accent);
387
388    let header_text = text("Commit Log").size(14).color(c.text_primary);
389
390    let multi_count = tab.selected_commits.len();
391    let commit_count: iced::widget::Text<'_, iced::Theme> = if multi_count > 1 {
392        text(format!("({} selected)", multi_count))
393            .size(12)
394            .color(c.accent)
395    } else {
396        text(format!("({})", tab.commits.len()))
397            .size(12)
398            .color(c.muted)
399    };
400
401    let header_row = row![
402        header_icon,
403        Space::new().width(6),
404        header_text,
405        Space::new().width(6),
406        commit_count,
407    ]
408    .align_y(Alignment::Center)
409    .padding([8, 10]);
410
411    if tab.commits.is_empty() {
412        let empty_msg = text("No commits yet.").size(14).color(c.muted);
413
414        let content = column![
415            header_row,
416            container(empty_msg)
417                .width(Length::Fill)
418                .padding(20)
419                .center_x(Length::Fill),
420        ]
421        .width(Length::Fill)
422        .height(Length::Fill);
423
424        return view_utils::surface_panel(content, Length::Fill);
425    }
426
427    // ── Virtual scroll window ─────────────────────────────────────────────
428    //
429    // Only the rows visible in the viewport (plus OVERSCAN above/below) are
430    // constructed as widgets.  The remaining space is filled with two Space
431    // widgets so the scrollable keeps the correct total height and the
432    // scrollbar thumb stays proportional.
433
434    let total = tab.commits.len();
435    let scroll_y = tab.commit_scroll_offset;
436
437    let first = ((scroll_y / ROW_HEIGHT) as usize).saturating_sub(OVERSCAN);
438    let last = (first + VISIBLE_ROWS + 2 * OVERSCAN).min(total);
439
440    let top_space = first as f32 * ROW_HEIGHT;
441    let bottom_space = (total - last) as f32 * ROW_HEIGHT;
442
443    let mut list_col = column![].width(Length::Fill);
444
445    if top_space > 0.0 {
446        list_col = list_col.push(Space::new().height(top_space));
447    }
448
449    // Author column scales with commit log width: ~15% of log width, clamped to [90, 180].
450    let author_width = (state.commit_log_width * 0.15).clamp(90.0, 180.0);
451
452    // Available px for the summary column:
453    // commit_log_width minus graph (~30) + oid (~56) + spaces + author + time (72) + padding (16).
454    let fixed_overhead = 30.0 + 56.0 + 22.0 + author_width + 72.0 + 16.0;
455    let available_summary_px = (state.commit_log_width - fixed_overhead).max(40.0);
456
457    let selected_range = tab.selected_commits.as_slice();
458
459    for idx in first..last {
460        list_col = list_col.push(commit_row_element(
461            tab,
462            idx,
463            &c,
464            available_summary_px,
465            author_width,
466            selected_range,
467        ));
468    }
469
470    if bottom_space > 0.0 {
471        list_col = list_col.push(Space::new().height(bottom_space));
472    }
473
474    // Loading spinner shown while a background fetch is in progress.
475    if tab.is_loading_more_commits {
476        list_col = list_col.push(
477            container(text("Loading more commits…").size(12).color(c.muted))
478                .width(Length::Fill)
479                .center_x(Length::Fill)
480                .padding([10, 0]),
481        );
482    }
483    // End-of-history marker once all commits are loaded.
484    if !tab.has_more_commits {
485        list_col = list_col.push(
486            container(text("— end of history —").size(11).color(c.muted))
487                .width(Length::Fill)
488                .center_x(Length::Fill)
489                .padding([10, 0]),
490        );
491    }
492
493    let commit_scroll = scrollable(list_col)
494        .height(Length::Fill)
495        .id(commit_log_scroll_id(state.active_tab))
496        .on_scroll(|vp| Message::CommitLogScrolled(vp.absolute_offset().y, vp.relative_offset().y))
497        .direction(view_utils::thin_scrollbar())
498        .style(crate::theme::overlay_scrollbar);
499
500    let content = column![header_row, commit_scroll]
501        .width(Length::Fill)
502        .height(Length::Fill);
503
504    view_utils::surface_panel(content, Length::Fill)
505}