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