1use super::hunk_groups;
18use crate::app::{AppAction, AppMode, AppState, KeyCommand};
19use crate::fragmap::TouchKind;
20use ratatui::{
21 Frame,
22 layout::{Constraint, Layout, Rect},
23 style::{Color, Style},
24 text::{Line, Span},
25 widgets::{Cell, Paragraph, Row, Scrollbar, ScrollbarOrientation, ScrollbarState, Table},
26};
27
28pub fn handle_key(action: KeyCommand, app: &mut AppState) -> AppAction {
30 match action {
31 KeyCommand::MoveUp => {
32 if app.reverse {
33 app.move_down();
34 } else {
35 app.move_up();
36 }
37 AppAction::Handled
38 }
39 KeyCommand::MoveDown => {
40 if app.reverse {
41 app.move_up();
42 } else {
43 app.move_down();
44 }
45 AppAction::Handled
46 }
47 KeyCommand::PageUp => {
48 let h = app.commit_list_visible_height;
49 if app.reverse {
50 app.page_down(h);
51 } else {
52 app.page_up(h);
53 }
54 AppAction::Handled
55 }
56 KeyCommand::PageDown => {
57 let h = app.commit_list_visible_height;
58 if app.reverse {
59 app.page_up(h);
60 } else {
61 app.page_down(h);
62 }
63 AppAction::Handled
64 }
65 KeyCommand::ScrollLeft => {
66 app.scroll_fragmap_left();
67 AppAction::Handled
68 }
69 KeyCommand::ScrollRight => {
70 app.scroll_fragmap_right();
71 AppAction::Handled
72 }
73 KeyCommand::ToggleDetail | KeyCommand::Confirm => {
74 app.toggle_detail_view();
75 AppAction::Handled
76 }
77 KeyCommand::ShowHelp => {
78 app.toggle_help();
79 AppAction::Handled
80 }
81 KeyCommand::Split => {
82 app.enter_split_select();
83 AppAction::Handled
84 }
85 KeyCommand::Squash => {
86 app.enter_squash_select();
87 AppAction::Handled
88 }
89 KeyCommand::Fixup => {
90 app.enter_fixup_select();
91 AppAction::Handled
92 }
93 KeyCommand::Drop => {
94 let commit = &app.commits[app.selection_index];
95 if commit.oid == "staged" || commit.oid == "unstaged" {
96 app.set_error_message("Cannot drop staged/unstaged changes");
97 AppAction::Handled
98 } else {
99 AppAction::PrepareDropConfirm {
100 commit_oid: commit.oid.clone(),
101 commit_summary: commit.summary.clone(),
102 }
103 }
104 }
105 KeyCommand::Reword => {
106 let commit = &app.commits[app.selection_index];
107 if commit.oid == "staged" || commit.oid == "unstaged" {
108 app.set_error_message("Cannot reword staged/unstaged changes");
109 AppAction::Handled
110 } else {
111 AppAction::PrepareReword {
112 commit_oid: commit.oid.clone(),
113 current_message: commit.message.clone(),
114 }
115 }
116 }
117 KeyCommand::Update => AppAction::ReloadCommits,
118 KeyCommand::Quit => AppAction::Quit,
119 KeyCommand::Move => {
120 app.enter_move_select();
121 AppAction::Handled
122 }
123 KeyCommand::Mergetool | KeyCommand::None | KeyCommand::ForceQuit => AppAction::Handled,
124 KeyCommand::SeparatorLeft => {
125 app.separator_offset = app.separator_offset.saturating_sub(4);
126 AppAction::Handled
127 }
128 KeyCommand::SeparatorRight => {
129 app.separator_offset = app.separator_offset.saturating_add(4);
130 AppAction::Handled
131 }
132 }
133}
134
135const SHORT_SHA_LENGTH: usize = 8;
137
138const HEADER_STYLE: Style = Style::new().fg(Color::White).bg(Color::Green);
139const FOOTER_STYLE: Style = Style::new().fg(Color::White).bg(Color::Blue);
140const SEPARATOR_STYLE: Style = Style::new().fg(Color::White).bg(Color::Blue);
141
142const COLOR_SYNTHETIC_LABEL: Color = Color::Cyan;
145
146const COLOR_ACTION_SOURCE_BG: Color = Color::Rgb(0, 120, 120);
148const COLOR_ACTION_TARGET_BG: Color = Color::Rgb(0, 40, 50);
149const COLOR_ACTION_INSERT_BG: Color = Color::Rgb(40, 40, 100);
150
151const MAX_TITLE_WIDTH: u16 = 60;
153
154struct LayoutInfo {
156 table_area: Rect,
157 footer_area: Rect,
158 h_scrollbar_area: Option<Rect>,
159 available_height: usize,
160 has_v_scrollbar: bool,
161 visible_clusters: Vec<usize>,
162 display_clusters: Vec<usize>,
163 fragmap_col_width: u16,
164 title_width: u16,
165 fragmap_available_width: usize,
166 h_scroll_offset: usize,
167 visual_selection: usize,
168 scroll_offset: usize,
169}
170pub fn render(app: &mut AppState, frame: &mut Frame) {
175 render_in_area(app, frame, frame.area());
176}
177
178pub fn render_in_area_without_fragmap_cols(app: &mut AppState, frame: &mut Frame, area: Rect) {
182 let saved_fragmap = app.fragmap.take();
185 let layout = compute_layout(app, area);
186 app.fragmap = saved_fragmap;
187 render_in_area_with_layout(app, frame, layout);
188}
189
190pub fn render_in_area(app: &mut AppState, frame: &mut Frame, area: Rect) {
192 let layout = compute_layout(app, area);
193 render_in_area_with_layout(app, frame, layout);
194}
195
196fn render_in_area_with_layout(app: &mut AppState, frame: &mut Frame, layout: LayoutInfo) {
197 app.commit_list_visible_height = layout.available_height;
199
200 let header = build_header(&layout);
201 let rows = build_rows(app, &layout);
202
203 let constraints = build_constraints(&layout);
204
205 let (scrollbar_area, content_area) = if layout.has_v_scrollbar {
206 let [sb, content] = Layout::horizontal([Constraint::Length(1), Constraint::Min(0)])
207 .areas(layout.table_area);
208 (Some(sb), content)
209 } else {
210 (None, layout.table_area)
211 };
212
213 let table = Table::new(rows, constraints).header(header);
214 frame.render_widget(table, content_area);
215
216 if layout.fragmap_col_width > 0 {
217 let sep_x = content_area.x + 10 + 1 + layout.title_width;
218 let sep_height = if layout.h_scrollbar_area.is_some() {
219 content_area.height + 1
220 } else {
221 content_area.height
222 };
223 let sep_area = Rect {
224 x: sep_x,
225 y: content_area.y,
226 width: 1,
227 height: sep_height,
228 };
229 let sep_lines: Vec<Line> = (0..sep_height)
230 .map(|_| Line::from(Span::styled("│", SEPARATOR_STYLE)))
231 .collect();
232 frame.render_widget(Paragraph::new(sep_lines), sep_area);
233 }
234
235 if let Some(sb_area) = scrollbar_area {
236 render_vertical_scrollbar(frame, sb_area, &layout, app.commits.len());
237 }
238
239 render_footer(frame, app, layout.footer_area);
240
241 if let Some(hs_area) = layout.h_scrollbar_area {
242 hunk_groups::render_horizontal_scrollbar(
243 frame,
244 hs_area,
245 layout.title_width,
246 layout.fragmap_col_width,
247 layout.visible_clusters.len(),
248 layout.fragmap_available_width,
249 layout.h_scroll_offset,
250 );
251 }
252}
253
254fn compute_layout(app: &mut AppState, frame_area: Rect) -> LayoutInfo {
256 let visible_cluster_count = if let Some(ref fragmap) = app.fragmap {
257 (0..fragmap.clusters.len())
258 .filter(|&ci| fragmap.matrix.iter().any(|row| row[ci] != TouchKind::None))
259 .count()
260 } else {
261 0
262 };
263
264 let preliminary_fragmap_width = frame_area.width.saturating_sub(10 + 1 + 20 + 1 + 1) as usize;
265 let needs_h_scrollbar =
266 visible_cluster_count > 0 && visible_cluster_count > preliminary_fragmap_width;
267
268 let (table_area, h_scrollbar_area, footer_area) = if needs_h_scrollbar {
269 let [t, hs, f] = Layout::vertical([
270 Constraint::Min(0),
271 Constraint::Length(1),
272 Constraint::Length(1),
273 ])
274 .areas(frame_area);
275 (t, Some(hs), f)
276 } else {
277 let [t, f] =
278 Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).areas(frame_area);
279 (t, None, f)
280 };
281
282 let available_height = table_area.height.saturating_sub(1) as usize;
283 let has_v_scrollbar = !app.commits.is_empty() && app.commits.len() > available_height;
284 let effective_width = if has_v_scrollbar {
285 table_area.width.saturating_sub(1)
286 } else {
287 table_area.width
288 };
289
290 let visible_clusters: Vec<usize> = if let Some(ref fragmap) = app.fragmap {
291 (0..fragmap.clusters.len())
292 .filter(|&ci| fragmap.matrix.iter().any(|row| row[ci] != TouchKind::None))
293 .collect()
294 } else {
295 vec![]
296 };
297
298 let natural_fragmap_w = effective_width.saturating_sub(10 + 2 + 20) as usize;
301 let natural_h_scroll = app.fragmap_scroll_offset.min(
302 visible_clusters
303 .len()
304 .saturating_sub(natural_fragmap_w.max(1)),
305 );
306 let natural_frag_col_w: u16 = if visible_clusters.is_empty() {
307 0
308 } else {
309 let end = (natural_h_scroll + natural_fragmap_w).min(visible_clusters.len());
310 end.saturating_sub(natural_h_scroll) as u16
311 };
312 let natural_title = effective_width
313 .saturating_sub(10 + 2 + natural_frag_col_w)
314 .min(MAX_TITLE_WIDTH);
315
316 let max_title = effective_width.saturating_sub(10 + 2 + 1) as i32;
319 let min_title: i32 = 10;
320 let title_width: u16 = if max_title >= min_title {
321 let w = (natural_title as i32 + app.separator_offset as i32).clamp(min_title, max_title);
322 app.separator_offset = (w - natural_title as i32) as i16;
323 w as u16
324 } else {
325 app.separator_offset = 0;
326 0
327 };
328
329 let fragmap_available_width = effective_width.saturating_sub(10 + 2 + title_width) as usize;
332
333 let h_scroll_offset = app.fragmap_scroll_offset.min(
334 visible_clusters
335 .len()
336 .saturating_sub(fragmap_available_width.max(1)),
337 );
338 app.fragmap_scroll_offset = h_scroll_offset;
339
340 let display_clusters: Vec<usize> = if visible_clusters.is_empty() {
341 vec![]
342 } else {
343 let end = (h_scroll_offset + fragmap_available_width).min(visible_clusters.len());
344 visible_clusters[h_scroll_offset..end].to_vec()
345 };
346
347 let fragmap_col_width = display_clusters.len() as u16;
348
349 let visual_selection = if app.reverse {
350 app.commits
351 .len()
352 .saturating_sub(1)
353 .saturating_sub(app.selection_index)
354 } else {
355 app.selection_index
356 };
357
358 let scroll_offset =
359 if app.commits.is_empty() || available_height == 0 || visual_selection < available_height {
360 0
361 } else {
362 visual_selection.saturating_sub(available_height - 1)
363 };
364
365 LayoutInfo {
366 table_area,
367 footer_area,
368 h_scrollbar_area,
369 available_height,
370 has_v_scrollbar,
371 visible_clusters,
372 display_clusters,
373 fragmap_col_width,
374 title_width,
375 fragmap_available_width,
376 h_scroll_offset,
377 visual_selection,
378 scroll_offset,
379 }
380}
381
382fn build_header(layout: &LayoutInfo) -> Row<'static> {
383 let cells = if layout.fragmap_col_width > 0 {
384 vec![
385 Cell::from("SHA"),
386 Cell::from("Title"),
387 Cell::from("Hunk groups"),
388 ]
389 } else {
390 vec![Cell::from("SHA"), Cell::from("Title")]
391 };
392 Row::new(cells).style(HEADER_STYLE)
393}
394
395fn build_constraints(layout: &LayoutInfo) -> Vec<Constraint> {
396 if layout.fragmap_col_width > 0 {
397 vec![
398 Constraint::Length(10),
399 Constraint::Length(layout.title_width),
400 Constraint::Min(layout.fragmap_col_width),
401 ]
402 } else {
403 vec![Constraint::Length(10), Constraint::Min(0)]
407 }
408}
409
410fn build_rows<'a>(app: &AppState, layout: &LayoutInfo) -> Vec<Row<'a>> {
412 let display_commits: Vec<&crate::CommitInfo> = if app.reverse {
413 app.commits.iter().rev().collect()
414 } else {
415 app.commits.iter().collect()
416 };
417
418 let visible_commits = if display_commits.is_empty() {
419 &display_commits[..]
420 } else {
421 let separator_visible = match app.mode {
424 AppMode::MoveSelect {
425 source_index,
426 insert_before,
427 } if insert_before != source_index && insert_before != source_index + 1 => {
428 let vis_start = layout.scroll_offset;
429 let vis_end = (vis_start + layout.available_height).min(display_commits.len());
430 insert_before >= vis_start && insert_before <= vis_end
431 }
432 _ => false,
433 };
434 let height = if separator_visible {
435 layout.available_height.saturating_sub(1)
436 } else {
437 layout.available_height
438 };
439 let end = (layout.scroll_offset + height).min(display_commits.len());
440 &display_commits[layout.scroll_offset..end]
441 };
442
443 let squash_source_idx = match app.mode {
445 AppMode::SquashSelect { source_index, .. } => Some(source_index),
446 _ => None,
447 };
448
449 let move_info = match app.mode {
451 AppMode::MoveSelect {
452 source_index,
453 insert_before,
454 } => Some((source_index, insert_before)),
455 _ => None,
456 };
457
458 let mut rows: Vec<Row<'a>> = Vec::new();
459
460 for (visible_index, commit) in visible_commits.iter().enumerate() {
461 let visual_index = layout.scroll_offset + visible_index;
462
463 let commit_idx_in_fragmap = if app.reverse {
464 app.commits
465 .len()
466 .saturating_sub(1)
467 .saturating_sub(visual_index)
468 } else {
469 visual_index
470 };
471
472 if let Some((source_index, insert_before)) = move_info
474 && commit_idx_in_fragmap == insert_before
475 && insert_before != source_index
476 && insert_before != source_index + 1
477 {
478 rows.push(build_move_separator_row(app, layout, source_index));
479 }
480
481 let short_sha: String = commit.oid.chars().take(SHORT_SHA_LENGTH).collect();
482
483 let is_synthetic = commit.oid == "staged" || commit.oid == "unstaged";
484 let is_selected = visual_index == layout.visual_selection;
485 let is_squash_source = squash_source_idx.is_some_and(|si| commit_idx_in_fragmap == si);
486 let is_move_source = move_info.is_some_and(|(si, _)| commit_idx_in_fragmap == si);
487
488 let text_style = if let Some(source_idx) = squash_source_idx {
490 if is_squash_source {
492 Style::new().fg(Color::White)
493 } else if commit_idx_in_fragmap > source_idx {
494 Style::new().fg(Color::DarkGray)
495 } else if is_synthetic {
496 Style::new().fg(COLOR_SYNTHETIC_LABEL)
497 } else if let Some(ref fm) = app.fragmap {
498 hunk_groups::commit_text_style(fm, source_idx, commit_idx_in_fragmap)
499 } else {
500 Style::default()
501 }
502 } else if let Some((source_idx, _)) = move_info {
503 if is_move_source {
504 Style::new().fg(Color::DarkGray)
505 } else if is_synthetic {
506 Style::new().fg(COLOR_SYNTHETIC_LABEL)
507 } else if let Some(ref fm) = app.fragmap {
508 hunk_groups::commit_text_style(fm, source_idx, commit_idx_in_fragmap)
509 } else {
510 Style::default()
511 }
512 } else if !is_selected {
513 if is_synthetic {
515 Style::new().fg(COLOR_SYNTHETIC_LABEL)
516 } else if let Some(ref fm) = app.fragmap {
517 hunk_groups::commit_text_style(fm, app.selection_index, commit_idx_in_fragmap)
518 } else {
519 Style::default()
520 }
521 } else {
522 Style::default()
523 };
524
525 let text_cell_style = if is_squash_source || is_move_source {
528 text_style.fg(Color::White).bg(COLOR_ACTION_SOURCE_BG)
529 } else if is_selected && (squash_source_idx.is_some() || move_info.is_some()) {
530 text_style.bg(COLOR_ACTION_TARGET_BG).reversed()
531 } else if is_selected {
532 text_style.reversed()
533 } else {
534 text_style
535 };
536
537 let mut cells = vec![
538 Cell::from(Span::styled(short_sha, text_cell_style)),
539 Cell::from(Span::styled(commit.summary.clone(), text_cell_style)),
540 ];
541
542 if let Some(ref fragmap) = app.fragmap
543 && !layout.display_clusters.is_empty()
544 {
545 cells.push(hunk_groups::build_fragmap_cell(
546 fragmap,
547 commit_idx_in_fragmap,
548 &layout.display_clusters,
549 is_selected,
550 app.squashable_scope,
551 ));
552 }
553
554 rows.push(Row::new(cells));
555 }
556
557 if let Some((source_index, insert_before)) = move_info {
559 let last_visible_idx = layout.scroll_offset + visible_commits.len();
560 if insert_before >= last_visible_idx
561 && insert_before == app.commits.len()
562 && insert_before != source_index
563 && insert_before != source_index + 1
564 {
565 rows.push(build_move_separator_row(app, layout, source_index));
566 }
567 }
568
569 rows
570}
571
572fn build_move_separator_row<'a>(
574 app: &AppState,
575 layout: &LayoutInfo,
576 source_index: usize,
577) -> Row<'a> {
578 let source = app.commits.get(source_index);
579 let short_oid = source
580 .map(|c| {
581 if c.oid.len() >= SHORT_SHA_LENGTH {
582 &c.oid[..SHORT_SHA_LENGTH]
583 } else {
584 &c.oid
585 }
586 })
587 .unwrap_or("?");
588
589 let style = Style::new().fg(Color::White).bg(COLOR_ACTION_INSERT_BG);
590 let label = format!("▶ move {} here", short_oid);
591
592 let mut cells = vec![
593 Cell::from(Span::styled("", style)),
594 Cell::from(Span::styled(label, style)),
595 ];
596
597 if !layout.display_clusters.is_empty() {
598 cells.push(Cell::from(Span::styled("", style)));
599 }
600
601 Row::new(cells)
602}
603
604pub(crate) fn render_footer(frame: &mut Frame, app: &AppState, area: Rect) {
605 if let Some(msg) = &app.status_message {
606 let bg = if app.status_is_error {
607 Color::Red
608 } else {
609 Color::Green
610 };
611 let style = Style::new().fg(Color::White).bg(bg);
612 let footer = Paragraph::new(Span::styled(format!(" {}", msg), style)).style(style);
613 frame.render_widget(footer, area);
614 return;
615 }
616
617 if let AppMode::SquashSelect {
618 source_index,
619 is_fixup,
620 } = app.mode
621 {
622 render_squash_footer(frame, app, area, source_index, is_fixup);
623 return;
624 }
625
626 if let AppMode::MoveSelect {
627 source_index,
628 insert_before,
629 } = app.mode
630 {
631 render_move_footer(frame, app, area, source_index, insert_before);
632 return;
633 }
634
635 let text = if app.commits.is_empty() {
636 String::from("No commits")
637 } else {
638 let commit = &app.commits[app.selection_index];
639 let position = app.commits.len() - app.selection_index;
640 format!(" {} {}/{}", commit.oid, position, app.commits.len())
641 };
642
643 let footer = Paragraph::new(Span::styled(text, FOOTER_STYLE)).style(FOOTER_STYLE);
644 frame.render_widget(footer, area);
645}
646
647const ACTION_FOOTER_STYLE: Style = Style::new().fg(Color::White).bg(Color::Cyan);
648const ACTION_FOOTER_ACCENT: Style = Style::new().fg(Color::Gray).bg(Color::Cyan);
649
650fn render_squash_footer(
651 frame: &mut Frame,
652 app: &AppState,
653 area: Rect,
654 source_index: usize,
655 is_fixup: bool,
656) {
657 let source = match app.commits.get(source_index) {
658 Some(c) => c,
659 None => return,
660 };
661
662 let short_oid = if source.oid.len() >= SHORT_SHA_LENGTH {
663 &source.oid[..SHORT_SHA_LENGTH]
664 } else {
665 &source.oid
666 };
667
668 let label = if is_fixup { "Fixup" } else { "Squash" };
669
670 let max_summary_len = (area.width as usize)
671 .saturating_sub(
672 format!(" {label} \"\" into\u{2026} \u{b7} Enter confirm \u{b7} Esc cancel").len(),
673 )
674 .saturating_sub(short_oid.len());
675
676 let summary = if source.summary.len() > max_summary_len && max_summary_len > 3 {
677 format!("{}\u{2026}", &source.summary[..max_summary_len - 1])
678 } else {
679 source.summary.clone()
680 };
681
682 let line = Line::from(vec![
683 Span::styled(format!(" {label} "), ACTION_FOOTER_STYLE),
684 Span::styled(short_oid, ACTION_FOOTER_ACCENT),
685 Span::styled(format!(" \"{summary}\" into\u{2026}"), ACTION_FOOTER_STYLE),
686 Span::styled(" \u{b7} ", ACTION_FOOTER_STYLE),
687 Span::styled("Enter", ACTION_FOOTER_ACCENT),
688 Span::styled(" confirm \u{b7} ", ACTION_FOOTER_STYLE),
689 Span::styled("Esc", ACTION_FOOTER_ACCENT),
690 Span::styled(" cancel", ACTION_FOOTER_STYLE),
691 ]);
692
693 let footer = Paragraph::new(line).style(ACTION_FOOTER_STYLE);
694 frame.render_widget(footer, area);
695}
696
697fn render_move_footer(
698 frame: &mut Frame,
699 app: &AppState,
700 area: Rect,
701 source_index: usize,
702 _insert_before: usize,
703) {
704 let source = match app.commits.get(source_index) {
705 Some(c) => c,
706 None => return,
707 };
708
709 let short_oid = if source.oid.len() >= SHORT_SHA_LENGTH {
710 &source.oid[..SHORT_SHA_LENGTH]
711 } else {
712 &source.oid
713 };
714
715 let max_summary_len = (area.width as usize)
716 .saturating_sub(
717 " Move \"\" \u{b7} ↑/↓ pick position \u{b7} Enter confirm \u{b7} Esc cancel".len(),
718 )
719 .saturating_sub(short_oid.len());
720
721 let summary = if source.summary.len() > max_summary_len && max_summary_len > 3 {
722 format!("{}\u{2026}", &source.summary[..max_summary_len - 1])
723 } else {
724 source.summary.clone()
725 };
726
727 let line = Line::from(vec![
728 Span::styled(" Move ", ACTION_FOOTER_STYLE),
729 Span::styled(short_oid, ACTION_FOOTER_ACCENT),
730 Span::styled(format!(" \"{summary}\""), ACTION_FOOTER_STYLE),
731 Span::styled(" \u{b7} ", ACTION_FOOTER_STYLE),
732 Span::styled("↑/↓", ACTION_FOOTER_ACCENT),
733 Span::styled(" pick position \u{b7} ", ACTION_FOOTER_STYLE),
734 Span::styled("Enter", ACTION_FOOTER_ACCENT),
735 Span::styled(" confirm \u{b7} ", ACTION_FOOTER_STYLE),
736 Span::styled("Esc", ACTION_FOOTER_ACCENT),
737 Span::styled(" cancel", ACTION_FOOTER_STYLE),
738 ]);
739
740 let footer = Paragraph::new(line).style(ACTION_FOOTER_STYLE);
741 frame.render_widget(footer, area);
742}
743
744fn render_vertical_scrollbar(
745 frame: &mut Frame,
746 sb_area: Rect,
747 layout: &LayoutInfo,
748 commit_count: usize,
749) {
750 let mut state =
751 ScrollbarState::new(commit_count.saturating_sub(1)).position(layout.visual_selection);
752
753 let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalLeft)
754 .begin_symbol(None)
755 .end_symbol(None)
756 .track_symbol(Some("│"));
757
758 let data_area = Rect {
759 y: sb_area.y + 1,
760 height: layout.available_height as u16,
761 ..sb_area
762 };
763 frame.render_stateful_widget(scrollbar, data_area, &mut state);
764}