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
124 | KeyCommand::OpenEditor
125 | KeyCommand::None
126 | KeyCommand::ForceQuit => AppAction::Handled,
127 KeyCommand::SeparatorLeft => {
128 app.separator_offset = app.separator_offset.saturating_sub(4);
129 AppAction::Handled
130 }
131 KeyCommand::SeparatorRight => {
132 app.separator_offset = app.separator_offset.saturating_add(4);
133 AppAction::Handled
134 }
135 }
136}
137
138const SHORT_SHA_LENGTH: usize = 8;
140
141const HEADER_STYLE: Style = Style::new().fg(Color::White).bg(Color::Green);
142const FOOTER_STYLE: Style = Style::new().fg(Color::White).bg(Color::Blue);
143const SEPARATOR_STYLE: Style = Style::new().fg(Color::White).bg(Color::Blue);
144
145const COLOR_SYNTHETIC_LABEL: Color = Color::Cyan;
148
149const COLOR_ACTION_SOURCE_BG: Color = Color::Rgb(0, 120, 120);
151const COLOR_ACTION_TARGET_BG: Color = Color::Rgb(0, 40, 50);
152const COLOR_ACTION_INSERT_BG: Color = Color::Rgb(40, 40, 100);
153
154const MAX_TITLE_WIDTH: u16 = 60;
156
157struct LayoutInfo {
159 table_area: Rect,
160 footer_area: Rect,
161 h_scrollbar_area: Option<Rect>,
162 available_height: usize,
163 has_v_scrollbar: bool,
164 visible_clusters: Vec<usize>,
165 display_clusters: Vec<usize>,
166 fragmap_col_width: u16,
167 title_width: u16,
168 fragmap_available_width: usize,
169 h_scroll_offset: usize,
170 visual_selection: usize,
171 scroll_offset: usize,
172}
173pub fn render(app: &mut AppState, frame: &mut Frame) {
178 render_in_area(app, frame, frame.area());
179}
180
181pub fn render_in_area_without_fragmap_cols(app: &mut AppState, frame: &mut Frame, area: Rect) {
185 let saved_fragmap = app.fragmap.take();
188 let layout = compute_layout(app, area);
189 app.fragmap = saved_fragmap;
190 render_in_area_with_layout(app, frame, layout);
191}
192
193pub fn render_in_area(app: &mut AppState, frame: &mut Frame, area: Rect) {
195 let layout = compute_layout(app, area);
196 render_in_area_with_layout(app, frame, layout);
197}
198
199fn render_in_area_with_layout(app: &mut AppState, frame: &mut Frame, layout: LayoutInfo) {
200 app.commit_list_visible_height = layout.available_height;
202
203 let header = build_header(&layout);
204 let rows = build_rows(app, &layout);
205
206 let constraints = build_constraints(&layout);
207
208 let (scrollbar_area, content_area) = if layout.has_v_scrollbar {
209 let [sb, content] = Layout::horizontal([Constraint::Length(1), Constraint::Min(0)])
210 .areas(layout.table_area);
211 (Some(sb), content)
212 } else {
213 (None, layout.table_area)
214 };
215
216 let table = Table::new(rows, constraints).header(header);
217 frame.render_widget(table, content_area);
218
219 if layout.fragmap_col_width > 0 {
220 let sep_x = content_area.x + 10 + 1 + layout.title_width;
221 let sep_height = if layout.h_scrollbar_area.is_some() {
222 content_area.height + 1
223 } else {
224 content_area.height
225 };
226 let sep_area = Rect {
227 x: sep_x,
228 y: content_area.y,
229 width: 1,
230 height: sep_height,
231 };
232 let sep_lines: Vec<Line> = (0..sep_height)
233 .map(|_| Line::from(Span::styled("│", SEPARATOR_STYLE)))
234 .collect();
235 frame.render_widget(Paragraph::new(sep_lines), sep_area);
236 }
237
238 if let Some(sb_area) = scrollbar_area {
239 render_vertical_scrollbar(frame, sb_area, &layout, app.commits.len());
240 }
241
242 render_footer(frame, app, layout.footer_area);
243
244 if let Some(hs_area) = layout.h_scrollbar_area {
245 hunk_groups::render_horizontal_scrollbar(
246 frame,
247 hs_area,
248 layout.title_width,
249 layout.fragmap_col_width,
250 layout.visible_clusters.len(),
251 layout.fragmap_available_width,
252 layout.h_scroll_offset,
253 );
254 }
255}
256
257fn compute_layout(app: &mut AppState, frame_area: Rect) -> LayoutInfo {
259 let visible_cluster_count = if let Some(ref fragmap) = app.fragmap {
260 (0..fragmap.clusters.len())
261 .filter(|&ci| fragmap.matrix.iter().any(|row| row[ci] != TouchKind::None))
262 .count()
263 } else {
264 0
265 };
266
267 let preliminary_fragmap_width = frame_area.width.saturating_sub(10 + 1 + 20 + 1 + 1) as usize;
268 let needs_h_scrollbar =
269 visible_cluster_count > 0 && visible_cluster_count > preliminary_fragmap_width;
270
271 let (table_area, h_scrollbar_area, footer_area) = if needs_h_scrollbar {
272 let [t, hs, f] = Layout::vertical([
273 Constraint::Min(0),
274 Constraint::Length(1),
275 Constraint::Length(1),
276 ])
277 .areas(frame_area);
278 (t, Some(hs), f)
279 } else {
280 let [t, f] =
281 Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).areas(frame_area);
282 (t, None, f)
283 };
284
285 let available_height = table_area.height.saturating_sub(1) as usize;
286 let has_v_scrollbar = !app.commits.is_empty() && app.commits.len() > available_height;
287 let effective_width = if has_v_scrollbar {
288 table_area.width.saturating_sub(1)
289 } else {
290 table_area.width
291 };
292
293 let visible_clusters: Vec<usize> = if let Some(ref fragmap) = app.fragmap {
294 (0..fragmap.clusters.len())
295 .filter(|&ci| fragmap.matrix.iter().any(|row| row[ci] != TouchKind::None))
296 .collect()
297 } else {
298 vec![]
299 };
300
301 let natural_fragmap_w = effective_width.saturating_sub(10 + 2 + 20) as usize;
304 let natural_h_scroll = app.fragmap_scroll_offset.min(
305 visible_clusters
306 .len()
307 .saturating_sub(natural_fragmap_w.max(1)),
308 );
309 let natural_frag_col_w: u16 = if visible_clusters.is_empty() {
310 0
311 } else {
312 let end = (natural_h_scroll + natural_fragmap_w).min(visible_clusters.len());
313 end.saturating_sub(natural_h_scroll) as u16
314 };
315 let natural_title = effective_width
316 .saturating_sub(10 + 2 + natural_frag_col_w)
317 .min(MAX_TITLE_WIDTH);
318
319 let max_title = effective_width.saturating_sub(10 + 2 + 1) as i32;
322 let min_title: i32 = 10;
323 let title_width: u16 = if max_title >= min_title {
324 let w = (natural_title as i32 + app.separator_offset as i32).clamp(min_title, max_title);
325 app.separator_offset = (w - natural_title as i32) as i16;
326 w as u16
327 } else {
328 app.separator_offset = 0;
329 0
330 };
331
332 let fragmap_available_width = effective_width.saturating_sub(10 + 2 + title_width) as usize;
335
336 let h_scroll_offset = app.fragmap_scroll_offset.min(
337 visible_clusters
338 .len()
339 .saturating_sub(fragmap_available_width.max(1)),
340 );
341 app.fragmap_scroll_offset = h_scroll_offset;
342
343 let display_clusters: Vec<usize> = if visible_clusters.is_empty() {
344 vec![]
345 } else {
346 let end = (h_scroll_offset + fragmap_available_width).min(visible_clusters.len());
347 visible_clusters[h_scroll_offset..end].to_vec()
348 };
349
350 let fragmap_col_width = display_clusters.len() as u16;
351
352 let visual_selection = if app.reverse {
353 app.commits
354 .len()
355 .saturating_sub(1)
356 .saturating_sub(app.selection_index)
357 } else {
358 app.selection_index
359 };
360
361 let scroll_offset =
362 if app.commits.is_empty() || available_height == 0 || visual_selection < available_height {
363 0
364 } else {
365 visual_selection.saturating_sub(available_height - 1)
366 };
367
368 LayoutInfo {
369 table_area,
370 footer_area,
371 h_scrollbar_area,
372 available_height,
373 has_v_scrollbar,
374 visible_clusters,
375 display_clusters,
376 fragmap_col_width,
377 title_width,
378 fragmap_available_width,
379 h_scroll_offset,
380 visual_selection,
381 scroll_offset,
382 }
383}
384
385fn build_header(layout: &LayoutInfo) -> Row<'static> {
386 let cells = if layout.fragmap_col_width > 0 {
387 vec![
388 Cell::from("SHA"),
389 Cell::from("Title"),
390 Cell::from("Hunk groups"),
391 ]
392 } else {
393 vec![Cell::from("SHA"), Cell::from("Title")]
394 };
395 Row::new(cells).style(HEADER_STYLE)
396}
397
398fn build_constraints(layout: &LayoutInfo) -> Vec<Constraint> {
399 if layout.fragmap_col_width > 0 {
400 vec![
401 Constraint::Length(10),
402 Constraint::Length(layout.title_width),
403 Constraint::Min(layout.fragmap_col_width),
404 ]
405 } else {
406 vec![Constraint::Length(10), Constraint::Min(0)]
410 }
411}
412
413fn build_rows<'a>(app: &AppState, layout: &LayoutInfo) -> Vec<Row<'a>> {
415 let display_commits: Vec<&crate::CommitInfo> = if app.reverse {
416 app.commits.iter().rev().collect()
417 } else {
418 app.commits.iter().collect()
419 };
420
421 let visible_commits = if display_commits.is_empty() {
422 &display_commits[..]
423 } else {
424 let separator_visible = match app.mode {
427 AppMode::MoveSelect {
428 source_index,
429 insert_before,
430 } if insert_before != source_index && insert_before != source_index + 1 => {
431 let vis_start = layout.scroll_offset;
432 let vis_end = (vis_start + layout.available_height).min(display_commits.len());
433 insert_before >= vis_start && insert_before <= vis_end
434 }
435 _ => false,
436 };
437 let height = if separator_visible {
438 layout.available_height.saturating_sub(1)
439 } else {
440 layout.available_height
441 };
442 let end = (layout.scroll_offset + height).min(display_commits.len());
443 &display_commits[layout.scroll_offset..end]
444 };
445
446 let squash_source_idx = match app.mode {
448 AppMode::SquashSelect { source_index, .. } => Some(source_index),
449 _ => None,
450 };
451
452 let move_info = match app.mode {
454 AppMode::MoveSelect {
455 source_index,
456 insert_before,
457 } => Some((source_index, insert_before)),
458 _ => None,
459 };
460
461 let mut rows: Vec<Row<'a>> = Vec::new();
462
463 for (visible_index, commit) in visible_commits.iter().enumerate() {
464 let visual_index = layout.scroll_offset + visible_index;
465
466 let commit_idx_in_fragmap = if app.reverse {
467 app.commits
468 .len()
469 .saturating_sub(1)
470 .saturating_sub(visual_index)
471 } else {
472 visual_index
473 };
474
475 if let Some((source_index, insert_before)) = move_info
477 && commit_idx_in_fragmap == insert_before
478 && insert_before != source_index
479 && insert_before != source_index + 1
480 {
481 rows.push(build_move_separator_row(app, layout, source_index));
482 }
483
484 let short_sha: String = commit.oid.chars().take(SHORT_SHA_LENGTH).collect();
485
486 let is_synthetic = commit.oid == "staged" || commit.oid == "unstaged";
487 let is_selected = visual_index == layout.visual_selection;
488 let is_squash_source = squash_source_idx.is_some_and(|si| commit_idx_in_fragmap == si);
489 let is_move_source = move_info.is_some_and(|(si, _)| commit_idx_in_fragmap == si);
490
491 let text_style = if let Some(source_idx) = squash_source_idx {
493 if is_squash_source {
495 Style::new().fg(Color::White)
496 } else if commit_idx_in_fragmap > source_idx {
497 Style::new().fg(Color::DarkGray)
498 } else if is_synthetic {
499 Style::new().fg(COLOR_SYNTHETIC_LABEL)
500 } else if let Some(ref fm) = app.fragmap {
501 hunk_groups::commit_text_style(fm, source_idx, commit_idx_in_fragmap)
502 } else {
503 Style::default()
504 }
505 } else if let Some((source_idx, _)) = move_info {
506 if is_move_source {
507 Style::new().fg(Color::DarkGray)
508 } else if is_synthetic {
509 Style::new().fg(COLOR_SYNTHETIC_LABEL)
510 } else if let Some(ref fm) = app.fragmap {
511 hunk_groups::commit_text_style(fm, source_idx, commit_idx_in_fragmap)
512 } else {
513 Style::default()
514 }
515 } else if !is_selected {
516 if is_synthetic {
518 Style::new().fg(COLOR_SYNTHETIC_LABEL)
519 } else if let Some(ref fm) = app.fragmap {
520 hunk_groups::commit_text_style(fm, app.selection_index, commit_idx_in_fragmap)
521 } else {
522 Style::default()
523 }
524 } else {
525 Style::default()
526 };
527
528 let text_cell_style = if is_squash_source || is_move_source {
531 text_style.fg(Color::White).bg(COLOR_ACTION_SOURCE_BG)
532 } else if is_selected && (squash_source_idx.is_some() || move_info.is_some()) {
533 text_style.bg(COLOR_ACTION_TARGET_BG).reversed()
534 } else if is_selected {
535 text_style.reversed()
536 } else {
537 text_style
538 };
539
540 let mut cells = vec![
541 Cell::from(Span::styled(short_sha, text_cell_style)),
542 Cell::from(Span::styled(commit.summary.clone(), text_cell_style)),
543 ];
544
545 if let Some(ref fragmap) = app.fragmap
546 && !layout.display_clusters.is_empty()
547 {
548 cells.push(hunk_groups::build_fragmap_cell(
549 fragmap,
550 commit_idx_in_fragmap,
551 &layout.display_clusters,
552 is_selected,
553 app.squashable_scope,
554 ));
555 }
556
557 rows.push(Row::new(cells));
558 }
559
560 if let Some((source_index, insert_before)) = move_info {
562 let last_visible_idx = layout.scroll_offset + visible_commits.len();
563 if insert_before >= last_visible_idx
564 && insert_before == app.commits.len()
565 && insert_before != source_index
566 && insert_before != source_index + 1
567 {
568 rows.push(build_move_separator_row(app, layout, source_index));
569 }
570 }
571
572 rows
573}
574
575fn build_move_separator_row<'a>(
577 app: &AppState,
578 layout: &LayoutInfo,
579 source_index: usize,
580) -> Row<'a> {
581 let source = app.commits.get(source_index);
582 let short_oid = source
583 .map(|c| {
584 if c.oid.len() >= SHORT_SHA_LENGTH {
585 &c.oid[..SHORT_SHA_LENGTH]
586 } else {
587 &c.oid
588 }
589 })
590 .unwrap_or("?");
591
592 let style = Style::new().fg(Color::White).bg(COLOR_ACTION_INSERT_BG);
593 let label = format!("▶ move {} here", short_oid);
594
595 let mut cells = vec![
596 Cell::from(Span::styled("", style)),
597 Cell::from(Span::styled(label, style)),
598 ];
599
600 if !layout.display_clusters.is_empty() {
601 cells.push(Cell::from(Span::styled("", style)));
602 }
603
604 Row::new(cells)
605}
606
607pub(crate) fn render_footer(frame: &mut Frame, app: &AppState, area: Rect) {
608 if let Some(msg) = &app.status_message {
609 let bg = if app.status_is_error {
610 Color::Red
611 } else {
612 Color::Green
613 };
614 let style = Style::new().fg(Color::White).bg(bg);
615 let footer = Paragraph::new(Span::styled(format!(" {}", msg), style)).style(style);
616 frame.render_widget(footer, area);
617 return;
618 }
619
620 if let AppMode::SquashSelect {
621 source_index,
622 is_fixup,
623 } = app.mode
624 {
625 render_squash_footer(frame, app, area, source_index, is_fixup);
626 return;
627 }
628
629 if let AppMode::MoveSelect {
630 source_index,
631 insert_before,
632 } = app.mode
633 {
634 render_move_footer(frame, app, area, source_index, insert_before);
635 return;
636 }
637
638 let text = if app.commits.is_empty() {
639 String::from("No commits")
640 } else {
641 let commit = &app.commits[app.selection_index];
642 let position = app.commits.len() - app.selection_index;
643 format!(" {} {}/{}", commit.oid, position, app.commits.len())
644 };
645
646 let footer = Paragraph::new(Span::styled(text, FOOTER_STYLE)).style(FOOTER_STYLE);
647 frame.render_widget(footer, area);
648}
649
650const ACTION_FOOTER_STYLE: Style = Style::new().fg(Color::White).bg(Color::Cyan);
651const ACTION_FOOTER_ACCENT: Style = Style::new().fg(Color::Gray).bg(Color::Cyan);
652
653fn render_squash_footer(
654 frame: &mut Frame,
655 app: &AppState,
656 area: Rect,
657 source_index: usize,
658 is_fixup: bool,
659) {
660 let source = match app.commits.get(source_index) {
661 Some(c) => c,
662 None => return,
663 };
664
665 let short_oid = if source.oid.len() >= SHORT_SHA_LENGTH {
666 &source.oid[..SHORT_SHA_LENGTH]
667 } else {
668 &source.oid
669 };
670
671 let label = if is_fixup { "Fixup" } else { "Squash" };
672
673 let max_summary_len = (area.width as usize)
674 .saturating_sub(
675 format!(" {label} \"\" into\u{2026} \u{b7} Enter confirm \u{b7} Esc cancel").len(),
676 )
677 .saturating_sub(short_oid.len());
678
679 let summary = if source.summary.len() > max_summary_len && max_summary_len > 3 {
680 format!("{}\u{2026}", &source.summary[..max_summary_len - 1])
681 } else {
682 source.summary.clone()
683 };
684
685 let line = Line::from(vec![
686 Span::styled(format!(" {label} "), ACTION_FOOTER_STYLE),
687 Span::styled(short_oid, ACTION_FOOTER_ACCENT),
688 Span::styled(format!(" \"{summary}\" into\u{2026}"), ACTION_FOOTER_STYLE),
689 Span::styled(" \u{b7} ", ACTION_FOOTER_STYLE),
690 Span::styled("Enter", ACTION_FOOTER_ACCENT),
691 Span::styled(" confirm \u{b7} ", ACTION_FOOTER_STYLE),
692 Span::styled("Esc", ACTION_FOOTER_ACCENT),
693 Span::styled(" cancel", ACTION_FOOTER_STYLE),
694 ]);
695
696 let footer = Paragraph::new(line).style(ACTION_FOOTER_STYLE);
697 frame.render_widget(footer, area);
698}
699
700fn render_move_footer(
701 frame: &mut Frame,
702 app: &AppState,
703 area: Rect,
704 source_index: usize,
705 _insert_before: usize,
706) {
707 let source = match app.commits.get(source_index) {
708 Some(c) => c,
709 None => return,
710 };
711
712 let short_oid = if source.oid.len() >= SHORT_SHA_LENGTH {
713 &source.oid[..SHORT_SHA_LENGTH]
714 } else {
715 &source.oid
716 };
717
718 let max_summary_len = (area.width as usize)
719 .saturating_sub(
720 " Move \"\" \u{b7} ↑/↓ pick position \u{b7} Enter confirm \u{b7} Esc cancel".len(),
721 )
722 .saturating_sub(short_oid.len());
723
724 let summary = if source.summary.len() > max_summary_len && max_summary_len > 3 {
725 format!("{}\u{2026}", &source.summary[..max_summary_len - 1])
726 } else {
727 source.summary.clone()
728 };
729
730 let line = Line::from(vec![
731 Span::styled(" Move ", ACTION_FOOTER_STYLE),
732 Span::styled(short_oid, ACTION_FOOTER_ACCENT),
733 Span::styled(format!(" \"{summary}\""), ACTION_FOOTER_STYLE),
734 Span::styled(" \u{b7} ", ACTION_FOOTER_STYLE),
735 Span::styled("↑/↓", ACTION_FOOTER_ACCENT),
736 Span::styled(" pick position \u{b7} ", ACTION_FOOTER_STYLE),
737 Span::styled("Enter", ACTION_FOOTER_ACCENT),
738 Span::styled(" confirm \u{b7} ", ACTION_FOOTER_STYLE),
739 Span::styled("Esc", ACTION_FOOTER_ACCENT),
740 Span::styled(" cancel", ACTION_FOOTER_STYLE),
741 ]);
742
743 let footer = Paragraph::new(line).style(ACTION_FOOTER_STYLE);
744 frame.render_widget(footer, area);
745}
746
747fn render_vertical_scrollbar(
748 frame: &mut Frame,
749 sb_area: Rect,
750 layout: &LayoutInfo,
751 commit_count: usize,
752) {
753 let mut state =
754 ScrollbarState::new(commit_count.saturating_sub(1)).position(layout.visual_selection);
755
756 let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalLeft)
757 .begin_symbol(None)
758 .end_symbol(None)
759 .track_symbol(Some("│"));
760
761 let data_area = Rect {
762 y: sb_area.y + 1,
763 height: layout.available_height as u16,
764 ..sb_area
765 };
766 frame.render_stateful_widget(scrollbar, data_area, &mut state);
767}