gitkraft_gui/features/commits/
view.rs1use 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
23fn 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
35fn 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
46fn 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
80const ROW_HEIGHT: f32 = 26.0;
84
85const OVERSCAN: usize = 8;
88
89const VISIBLE_ROWS: usize = 50;
92
93pub fn commit_log_scroll_id(tab_index: usize) -> iced::widget::Id {
96 iced::widget::Id::from(format!("commit_log_{tab_index}"))
97}
98
99fn 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
237fn 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 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 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 let badges = ref_badges_row(&commit.refs, c, 3);
281 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 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 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 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
379pub 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 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 let author_width = (state.commit_log_width * 0.15).clamp(90.0, 180.0);
451
452 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 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 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}