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 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
69const ROW_HEIGHT: f32 = 26.0;
73
74const OVERSCAN: usize = 8;
77
78const VISIBLE_ROWS: usize = 50;
81
82pub fn commit_log_scroll_id(tab_index: usize) -> iced::widget::Id {
85 iced::widget::Id::from(format!("commit_log_{tab_index}"))
86}
87
88fn 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
226fn 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 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 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 let badges = ref_badges_row(&commit.refs, c, 3);
270 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 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 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 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
368pub 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 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 let author_width = (state.commit_log_width * 0.15).clamp(90.0, 180.0);
440
441 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 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 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}