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/// Estimated height of one commit row in pixels.  Used for virtual scrolling.
24/// A slight over- or under-estimate only affects scrollbar thumb precision,
25/// not correctness of the rendered content.
26const ROW_HEIGHT: f32 = 26.0;
27
28/// Rows rendered above and below the visible window (avoids pop-in during
29/// fast scrolling).
30const OVERSCAN: usize = 8;
31
32/// Assumed visible rows (covers a 1300 px tall viewport at ROW_HEIGHT).
33/// Making this generous costs almost nothing — we cap at `total` anyway.
34const VISIBLE_ROWS: usize = 50;
35
36/// Per-tab stable scroll id — Iced maintains a separate scroll position for
37/// each open tab so no programmatic `scroll_to` is needed on tab switches.
38pub fn commit_log_scroll_id(tab_index: usize) -> iced::widget::Id {
39    iced::widget::Id::from(format!("commit_log_{tab_index}"))
40}
41
42// ── graph_cell ────────────────────────────────────────────────────────────────
43
44/// Build a small `Row` of individually-coloured text elements representing one
45/// row of the commit graph.
46fn graph_cell<'a>(
47    graph_row: &gitkraft_core::GraphRow,
48    graph_colors: &[Color; 8],
49) -> Row<'a, Message> {
50    let width = graph_row.width;
51    let len = graph_colors.len();
52
53    if width == 0 {
54        return Row::new().push(
55            text("● ")
56                .font(iced::Font::MONOSPACE)
57                .size(12)
58                .color(graph_colors[graph_row.node_color % len]),
59        );
60    }
61
62    let mut column_passthrough: Vec<Option<usize>> = vec![None; width];
63    let mut has_left_cross = false;
64    let mut has_right_cross = false;
65    let mut left_cross_color: usize = 0;
66    let mut right_cross_color: usize = 0;
67    let mut cross_left_col: usize = graph_row.node_column;
68    let mut cross_right_col: usize = graph_row.node_column;
69
70    for edge in &graph_row.edges {
71        if edge.from_column == edge.to_column {
72            column_passthrough[edge.to_column] = Some(edge.color_index);
73        } else {
74            let target = edge.to_column;
75            if target < graph_row.node_column {
76                has_left_cross = true;
77                left_cross_color = edge.color_index;
78                if target < cross_left_col {
79                    cross_left_col = target;
80                }
81            } else if target > graph_row.node_column {
82                has_right_cross = true;
83                right_cross_color = edge.color_index;
84                if target > cross_right_col {
85                    cross_right_col = target;
86                }
87            }
88        }
89    }
90
91    let mut cells: Vec<Element<'a, Message>> = Vec::with_capacity(width);
92
93    for col in 0..width {
94        if col == graph_row.node_column {
95            let color = graph_colors[graph_row.node_color % len];
96            cells.push(
97                text("● ")
98                    .font(iced::Font::MONOSPACE)
99                    .size(12)
100                    .color(color)
101                    .into(),
102            );
103        } else if let Some(ci) = column_passthrough.get(col).copied().flatten() {
104            let in_left = has_left_cross && col >= cross_left_col && col < graph_row.node_column;
105            let in_right = has_right_cross && col > graph_row.node_column && col <= cross_right_col;
106
107            if in_left || in_right {
108                let cross_ci = if in_left {
109                    left_cross_color
110                } else {
111                    right_cross_color
112                };
113                cells.push(
114                    text("├─")
115                        .font(iced::Font::MONOSPACE)
116                        .size(12)
117                        .color(graph_colors[cross_ci % len])
118                        .into(),
119                );
120            } else {
121                cells.push(
122                    text("│ ")
123                        .font(iced::Font::MONOSPACE)
124                        .size(12)
125                        .color(graph_colors[ci % len])
126                        .into(),
127                );
128            }
129        } else {
130            let in_left = has_left_cross && col >= cross_left_col && col < graph_row.node_column;
131            let in_right = has_right_cross && col > graph_row.node_column && col <= cross_right_col;
132
133            if in_left {
134                let color = graph_colors[left_cross_color % len];
135                if col == cross_left_col {
136                    cells.push(
137                        text("╭─")
138                            .font(iced::Font::MONOSPACE)
139                            .size(12)
140                            .color(color)
141                            .into(),
142                    );
143                } else {
144                    cells.push(
145                        text("──")
146                            .font(iced::Font::MONOSPACE)
147                            .size(12)
148                            .color(color)
149                            .into(),
150                    );
151                }
152            } else if in_right {
153                let color = graph_colors[right_cross_color % len];
154                if col == cross_right_col {
155                    cells.push(
156                        text("─╮")
157                            .font(iced::Font::MONOSPACE)
158                            .size(12)
159                            .color(color)
160                            .into(),
161                    );
162                } else {
163                    cells.push(
164                        text("──")
165                            .font(iced::Font::MONOSPACE)
166                            .size(12)
167                            .color(color)
168                            .into(),
169                    );
170                }
171            } else {
172                cells.push(text("  ").font(iced::Font::MONOSPACE).size(12).into());
173            }
174        }
175    }
176
177    Row::with_children(cells).align_y(Alignment::Center)
178}
179
180// ── single row element ────────────────────────────────────────────────────────
181
182/// Build the widget for a single commit row.
183fn commit_row_element<'a>(
184    tab: &'a RepoTab,
185    idx: usize,
186    c: &ThemeColors,
187    available_summary_px: f32,
188    author_width: f32,
189) -> Element<'a, Message> {
190    let commit = &tab.commits[idx];
191    let is_selected = tab.selected_commit == Some(idx);
192
193    // Graph column
194    let graph_elem: Element<'_, Message> = if let Some(grow) = tab.graph_rows.get(idx) {
195        graph_cell(grow, &c.graph_colors).into()
196    } else {
197        text("").into()
198    };
199
200    let oid_label = text(commit.short_oid.as_str())
201        .size(12)
202        .color(c.accent)
203        .font(iced::Font::MONOSPACE);
204
205    // Use pre-computed display strings; fall back gracefully if out of sync.
206    let (summary_str, time_str, author_str) = tab
207        .commit_display
208        .get(idx)
209        .map(|(s, t, a)| (s.as_str(), t.as_str(), a.as_str()))
210        .unwrap_or((commit.summary.as_str(), "", commit.author_name.as_str()));
211
212    // Pre-truncate with "…" so the full row stays on one line.
213    let display_summary = truncate_to_fit(summary_str, available_summary_px, 7.0);
214    let summary_label = container(
215        text(display_summary)
216            .size(12)
217            .color(c.text_primary)
218            .wrapping(iced::widget::text::Wrapping::None),
219    )
220    .width(Length::Fill)
221    .clip(true);
222
223    // Fixed-width columns prevent author / time from being squeezed to zero
224    // and wrapping character-by-character.  Text is pre-truncated so it fits.
225    let author_label = container(
226        text(author_str)
227            .size(11)
228            .color(c.text_secondary)
229            .wrapping(iced::widget::text::Wrapping::None),
230    )
231    .width(author_width)
232    .clip(true);
233
234    let time_label = container(
235        text(time_str)
236            .size(11)
237            .color(c.muted)
238            .wrapping(iced::widget::text::Wrapping::None),
239    )
240    .width(72)
241    .clip(true);
242
243    let row_content = row![
244        graph_elem,
245        oid_label,
246        Space::new().width(6),
247        summary_label,
248        Space::new().width(8),
249        author_label,
250        Space::new().width(8),
251        time_label,
252    ]
253    .align_y(Alignment::Center)
254    .padding([3, 8]);
255
256    let style_fn = if is_selected {
257        theme::selected_row_style as fn(&iced::Theme) -> iced::widget::container::Style
258    } else {
259        theme::surface_style as fn(&iced::Theme) -> iced::widget::container::Style
260    };
261
262    mouse_area(
263        container(
264            button(row_content)
265                .padding(0)
266                .width(Length::Fill)
267                .on_press(Message::SelectCommit(idx))
268                .style(theme::ghost_button),
269        )
270        .width(Length::Fill)
271        .height(Length::Fixed(ROW_HEIGHT))
272        .clip(true)
273        .style(style_fn),
274    )
275    .on_right_press(Message::OpenCommitContextMenu(idx))
276    .into()
277}
278
279// ── view ─────────────────────────────────────────────────────────────────────
280
281/// Render the commit log panel.
282pub fn view(state: &GitKraft) -> Element<'_, Message> {
283    let tab = state.active_tab();
284    let c = state.colors();
285
286    let header_icon = icon!(icons::CLOCK, 14, c.accent);
287
288    let header_text = text("Commit Log").size(14).color(c.text_primary);
289
290    let commit_count = text(format!("({})", tab.commits.len()))
291        .size(12)
292        .color(c.muted);
293
294    let header_row = row![
295        header_icon,
296        Space::new().width(6),
297        header_text,
298        Space::new().width(6),
299        commit_count,
300    ]
301    .align_y(Alignment::Center)
302    .padding([8, 10]);
303
304    if tab.commits.is_empty() {
305        let empty_msg = text("No commits yet.").size(14).color(c.muted);
306
307        let content = column![
308            header_row,
309            container(empty_msg)
310                .width(Length::Fill)
311                .padding(20)
312                .center_x(Length::Fill),
313        ]
314        .width(Length::Fill)
315        .height(Length::Fill);
316
317        return view_utils::surface_panel(content, Length::Fill);
318    }
319
320    // ── Virtual scroll window ─────────────────────────────────────────────
321    //
322    // Only the rows visible in the viewport (plus OVERSCAN above/below) are
323    // constructed as widgets.  The remaining space is filled with two Space
324    // widgets so the scrollable keeps the correct total height and the
325    // scrollbar thumb stays proportional.
326
327    let total = tab.commits.len();
328    let scroll_y = tab.commit_scroll_offset;
329
330    let first = ((scroll_y / ROW_HEIGHT) as usize).saturating_sub(OVERSCAN);
331    let last = (first + VISIBLE_ROWS + 2 * OVERSCAN).min(total);
332
333    let top_space = first as f32 * ROW_HEIGHT;
334    let bottom_space = (total - last) as f32 * ROW_HEIGHT;
335
336    let mut list_col = column![].width(Length::Fill);
337
338    if top_space > 0.0 {
339        list_col = list_col.push(Space::new().height(top_space));
340    }
341
342    // Author column scales with commit log width: ~15% of log width, clamped to [90, 180].
343    let author_width = (state.commit_log_width * 0.15).clamp(90.0, 180.0);
344
345    // Available px for the summary column:
346    // commit_log_width minus graph (~30) + oid (~56) + spaces + author + time (72) + padding (16).
347    let fixed_overhead = 30.0 + 56.0 + 22.0 + author_width + 72.0 + 16.0;
348    let available_summary_px = (state.commit_log_width - fixed_overhead).max(40.0);
349
350    for idx in first..last {
351        list_col = list_col.push(commit_row_element(
352            tab,
353            idx,
354            &c,
355            available_summary_px,
356            author_width,
357        ));
358    }
359
360    if bottom_space > 0.0 {
361        list_col = list_col.push(Space::new().height(bottom_space));
362    }
363
364    // Loading spinner shown while a background fetch is in progress.
365    if tab.is_loading_more_commits {
366        list_col = list_col.push(
367            container(text("Loading more commits…").size(12).color(c.muted))
368                .width(Length::Fill)
369                .center_x(Length::Fill)
370                .padding([10, 0]),
371        );
372    }
373    // End-of-history marker once all commits are loaded.
374    if !tab.has_more_commits {
375        list_col = list_col.push(
376            container(text("— end of history —").size(11).color(c.muted))
377                .width(Length::Fill)
378                .center_x(Length::Fill)
379                .padding([10, 0]),
380        );
381    }
382
383    let commit_scroll = scrollable(list_col)
384        .height(Length::Fill)
385        .id(commit_log_scroll_id(state.active_tab))
386        .on_scroll(|vp| Message::CommitLogScrolled(vp.absolute_offset().y, vp.relative_offset().y))
387        .direction(view_utils::thin_scrollbar())
388        .style(crate::theme::overlay_scrollbar);
389
390    let content = column![header_row, commit_scroll]
391        .width(Length::Fill)
392        .height(Length::Fill);
393
394    view_utils::surface_panel(content, Length::Fill)
395}