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
23const ROW_HEIGHT: f32 = 26.0;
27
28const OVERSCAN: usize = 8;
31
32const VISIBLE_ROWS: usize = 50;
35
36pub fn commit_log_scroll_id(tab_index: usize) -> iced::widget::Id {
39 iced::widget::Id::from(format!("commit_log_{tab_index}"))
40}
41
42fn 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
180fn 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 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 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 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 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 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
301pub 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 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 let author_width = (state.commit_log_width * 0.15).clamp(90.0, 180.0);
373
374 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 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 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}