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;
71
72pub fn commit_log_scroll_id(tab_index: usize) -> iced::widget::Id {
75 iced::widget::Id::from(format!("commit_log_{tab_index}"))
76}
77
78fn 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
216fn 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 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 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 let badges = ref_badges_row(&commit.refs, c, 3);
260 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 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 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 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
360pub 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 let total = tab.commits.len();
418
419 let author_width = (state.commit_log_width * 0.15).clamp(90.0, 180.0);
421
422 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 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 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}