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    selected_range: &[usize],
190) -> Element<'a, Message> {
191    let commit = &tab.commits[idx];
192    let is_selected = tab.selected_commit == Some(idx);
193
194    // Badge: position in the selected range (1-based), or blank space
195    let selection_badge: Element<'a, Message> =
196        if let Some(pos) = selected_range.iter().position(|&i| i == idx) {
197            container(
198                text(format!("{}", pos + 1))
199                    .size(10)
200                    .font(iced::Font::MONOSPACE)
201                    .color(c.accent),
202            )
203            .width(16)
204            .center_x(iced::Length::Fixed(16.0))
205            .into()
206        } else {
207            Space::new().width(16).into()
208        };
209
210    // Graph column
211    let graph_elem: Element<'_, Message> = if let Some(grow) = tab.graph_rows.get(idx) {
212        graph_cell(grow, &c.graph_colors).into()
213    } else {
214        text("").into()
215    };
216
217    let oid_label = text(commit.short_oid.as_str())
218        .size(12)
219        .color(c.accent)
220        .font(iced::Font::MONOSPACE);
221
222    // Use pre-computed display strings; fall back gracefully if out of sync.
223    let (summary_str, time_str, author_str) = tab
224        .commit_display
225        .get(idx)
226        .map(|(s, t, a)| (s.as_str(), t.as_str(), a.as_str()))
227        .unwrap_or((commit.summary.as_str(), "", commit.author_name.as_str()));
228
229    // Pre-truncate with "…" so the full row stays on one line.
230    let display_summary = truncate_to_fit(summary_str, available_summary_px, 7.0);
231    let summary_label = container(
232        text(display_summary)
233            .size(12)
234            .color(c.text_primary)
235            .wrapping(iced::widget::text::Wrapping::None),
236    )
237    .width(Length::Fill)
238    .clip(true);
239
240    // Fixed-width columns prevent author / time from being squeezed to zero
241    // and wrapping character-by-character.  Text is pre-truncated so it fits.
242    let author_label = container(
243        text(author_str)
244            .size(11)
245            .color(c.text_secondary)
246            .wrapping(iced::widget::text::Wrapping::None),
247    )
248    .width(author_width)
249    .clip(true);
250
251    let time_label = container(
252        text(time_str)
253            .size(11)
254            .color(c.muted)
255            .wrapping(iced::widget::text::Wrapping::None),
256    )
257    .width(72)
258    .clip(true);
259
260    let row_content = row![
261        selection_badge,
262        Space::new().width(2),
263        graph_elem,
264        oid_label,
265        Space::new().width(6),
266        summary_label,
267        Space::new().width(8),
268        author_label,
269        Space::new().width(8),
270        time_label,
271    ]
272    .align_y(Alignment::Center)
273    .padding([3, 8]);
274
275    let is_in_range = selected_range.contains(&idx);
276    let style_fn = if is_selected {
277        theme::selected_row_style as fn(&iced::Theme) -> iced::widget::container::Style
278    } else if is_in_range {
279        theme::highlight_row_style as fn(&iced::Theme) -> iced::widget::container::Style
280    } else {
281        theme::surface_style as fn(&iced::Theme) -> iced::widget::container::Style
282    };
283
284    mouse_area(
285        container(
286            button(row_content)
287                .padding(0)
288                .width(Length::Fill)
289                .on_press(Message::SelectCommit(idx))
290                .style(theme::ghost_button),
291        )
292        .width(Length::Fill)
293        .height(Length::Fixed(ROW_HEIGHT))
294        .clip(true)
295        .style(style_fn),
296    )
297    .on_right_press(Message::OpenCommitContextMenu(idx))
298    .into()
299}
300
301// ── view ─────────────────────────────────────────────────────────────────────
302
303/// Render the commit log panel.
304pub fn view(state: &GitKraft) -> Element<'_, Message> {
305    let tab = state.active_tab();
306    let c = state.colors();
307
308    let header_icon = icon!(icons::CLOCK, 14, c.accent);
309
310    let header_text = text("Commit Log").size(14).color(c.text_primary);
311
312    let multi_count = tab.selected_commits.len();
313    let commit_count: iced::widget::Text<'_, iced::Theme> = if multi_count > 1 {
314        text(format!("({} selected)", multi_count))
315            .size(12)
316            .color(c.accent)
317    } else {
318        text(format!("({})", tab.commits.len()))
319            .size(12)
320            .color(c.muted)
321    };
322
323    let header_row = row![
324        header_icon,
325        Space::new().width(6),
326        header_text,
327        Space::new().width(6),
328        commit_count,
329    ]
330    .align_y(Alignment::Center)
331    .padding([8, 10]);
332
333    if tab.commits.is_empty() {
334        let empty_msg = text("No commits yet.").size(14).color(c.muted);
335
336        let content = column![
337            header_row,
338            container(empty_msg)
339                .width(Length::Fill)
340                .padding(20)
341                .center_x(Length::Fill),
342        ]
343        .width(Length::Fill)
344        .height(Length::Fill);
345
346        return view_utils::surface_panel(content, Length::Fill);
347    }
348
349    // ── Virtual scroll window ─────────────────────────────────────────────
350    //
351    // Only the rows visible in the viewport (plus OVERSCAN above/below) are
352    // constructed as widgets.  The remaining space is filled with two Space
353    // widgets so the scrollable keeps the correct total height and the
354    // scrollbar thumb stays proportional.
355
356    let total = tab.commits.len();
357    let scroll_y = tab.commit_scroll_offset;
358
359    let first = ((scroll_y / ROW_HEIGHT) as usize).saturating_sub(OVERSCAN);
360    let last = (first + VISIBLE_ROWS + 2 * OVERSCAN).min(total);
361
362    let top_space = first as f32 * ROW_HEIGHT;
363    let bottom_space = (total - last) as f32 * ROW_HEIGHT;
364
365    let mut list_col = column![].width(Length::Fill);
366
367    if top_space > 0.0 {
368        list_col = list_col.push(Space::new().height(top_space));
369    }
370
371    // Author column scales with commit log width: ~15% of log width, clamped to [90, 180].
372    let author_width = (state.commit_log_width * 0.15).clamp(90.0, 180.0);
373
374    // Available px for the summary column:
375    // commit_log_width minus graph (~30) + oid (~56) + spaces + author + time (72) + padding (16).
376    let fixed_overhead = 30.0 + 56.0 + 22.0 + author_width + 72.0 + 16.0;
377    let available_summary_px = (state.commit_log_width - fixed_overhead).max(40.0);
378
379    let selected_range = tab.selected_commits.as_slice();
380
381    for idx in first..last {
382        list_col = list_col.push(commit_row_element(
383            tab,
384            idx,
385            &c,
386            available_summary_px,
387            author_width,
388            selected_range,
389        ));
390    }
391
392    if bottom_space > 0.0 {
393        list_col = list_col.push(Space::new().height(bottom_space));
394    }
395
396    // Loading spinner shown while a background fetch is in progress.
397    if tab.is_loading_more_commits {
398        list_col = list_col.push(
399            container(text("Loading more commits…").size(12).color(c.muted))
400                .width(Length::Fill)
401                .center_x(Length::Fill)
402                .padding([10, 0]),
403        );
404    }
405    // End-of-history marker once all commits are loaded.
406    if !tab.has_more_commits {
407        list_col = list_col.push(
408            container(text("— end of history —").size(11).color(c.muted))
409                .width(Length::Fill)
410                .center_x(Length::Fill)
411                .padding([10, 0]),
412        );
413    }
414
415    let commit_scroll = scrollable(list_col)
416        .height(Length::Fill)
417        .id(commit_log_scroll_id(state.active_tab))
418        .on_scroll(|vp| Message::CommitLogScrolled(vp.absolute_offset().y, vp.relative_offset().y))
419        .direction(view_utils::thin_scrollbar())
420        .style(crate::theme::overlay_scrollbar);
421
422    let content = column![header_row, commit_scroll]
423        .width(Length::Fill)
424        .height(Length::Fill);
425
426    view_utils::surface_panel(content, Length::Fill)
427}