Skip to main content

git_tailor/views/
commit_list.rs

1// Copyright 2026 Thomas Johannesson
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15// Commit list view rendering
16
17use 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
28/// Handle an action while in CommitList mode.
29pub 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
135/// Number of characters to display for short SHA.
136const 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
142// Foreground color for synthetic working-tree rows (staged / unstaged).
143// Applied when the row is not selected so they are visually distinct from commits.
144const COLOR_SYNTHETIC_LABEL: Color = Color::Cyan;
145
146// Colors shared across action modes (squash, move, …) for source, target, and insert-point rows.
147const 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
151/// Maximum width for the title column, keeping fragmap adjacent to titles.
152const MAX_TITLE_WIDTH: u16 = 60;
153
154/// Pre-computed layout information shared between rendering functions.
155struct 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}
170/// Render the commit list view.
171///
172/// Takes application state and renders the commit list to the terminal frame.
173/// If `area` is provided, uses that instead of the full frame area.
174pub fn render(app: &mut AppState, frame: &mut Frame) {
175    render_in_area(app, frame, frame.area());
176}
177
178/// Render the commit list view in a specific area, without showing fragmap columns.
179/// The fragmap is still used for row coloring; only the column display is suppressed.
180/// Used by the detail-panel split view where fragmap columns would be out of place.
181pub fn render_in_area_without_fragmap_cols(app: &mut AppState, frame: &mut Frame, area: Rect) {
182    // Hide fragmap only for layout (column sizing) so no fragmap columns appear;
183    // restore it before build_rows so commit_text_style still gets fragmap colors.
184    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
190/// Render the commit list view in a specific area.
191pub 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    // Store visible height for page scrolling
198    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
254/// Compute all layout dimensions, scroll offsets, and visible cluster indices.
255fn 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    // Establish the natural (separator_offset=0) baseline using the same formula as
299    // before T117: title is whatever fits after allocating maximum fragmap space.
300    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    // Apply separator_offset on top of the natural baseline. Clamp and write back
317    // immediately so reversing direction takes effect without delay.
318    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    // Derive fragmap available width from the final title_width so that the
330    // table constraints (SHA + title + fragmap) always sum to effective_width.
331    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        // Min(0) for title: SHA's Length(10) always wins over the title column
404        // so the SHA is never squished when the panel is narrow (e.g. the left
405        // sub-panel in CommitDetail view).
406        vec![Constraint::Length(10), Constraint::Min(0)]
407    }
408}
409
410/// Build all visible table rows.
411fn 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        // When a move separator row is visible it occupies one table row,
422        // so we show one fewer commit to keep everything within bounds.
423        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    // In SquashSelect mode, the source commit index drives candidate coloring.
444    let squash_source_idx = match app.mode {
445        AppMode::SquashSelect { source_index, .. } => Some(source_index),
446        _ => None,
447    };
448
449    // In MoveSelect mode, extract source and insertion point.
450    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        // Insert separator row before this commit if this is the insertion point.
473        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        // Determine text style based on mode and position.
489        let text_style = if let Some(source_idx) = squash_source_idx {
490            // SquashSelect mode: color by relation to squash source.
491            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            // Normal CommitList mode coloring for non-selected rows.
514            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        // Apply highlight: source gets teal bg, selection in an action mode gets
526        // a subtle target-bg tint plus reversed; plain selection gets reversed.
527        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    // Append separator after the last visible commit when moving to the end.
558    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
572/// Build the styled "▶ move here" separator row for MoveSelect mode.
573fn 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}