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