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
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
138/// Number of characters to display for short SHA.
139const 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
145// Foreground color for synthetic working-tree rows (staged / unstaged).
146// Applied when the row is not selected so they are visually distinct from commits.
147const COLOR_SYNTHETIC_LABEL: Color = Color::Cyan;
148
149// Colors shared across action modes (squash, move, …) for source, target, and insert-point rows.
150const 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
154/// Maximum width for the title column, keeping fragmap adjacent to titles.
155const MAX_TITLE_WIDTH: u16 = 60;
156
157/// Pre-computed layout information shared between rendering functions.
158struct 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}
173/// Render the commit list view.
174///
175/// Takes application state and renders the commit list to the terminal frame.
176/// If `area` is provided, uses that instead of the full frame area.
177pub fn render(app: &mut AppState, frame: &mut Frame) {
178    render_in_area(app, frame, frame.area());
179}
180
181/// Render the commit list view in a specific area, without showing fragmap columns.
182/// The fragmap is still used for row coloring; only the column display is suppressed.
183/// Used by the detail-panel split view where fragmap columns would be out of place.
184pub fn render_in_area_without_fragmap_cols(app: &mut AppState, frame: &mut Frame, area: Rect) {
185    // Hide fragmap only for layout (column sizing) so no fragmap columns appear;
186    // restore it before build_rows so commit_text_style still gets fragmap colors.
187    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
193/// Render the commit list view in a specific area.
194pub 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    // Store visible height for page scrolling
201    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
257/// Compute all layout dimensions, scroll offsets, and visible cluster indices.
258fn 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    // Establish the natural (separator_offset=0) baseline using the same formula as
302    // before T117: title is whatever fits after allocating maximum fragmap space.
303    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    // Apply separator_offset on top of the natural baseline. Clamp and write back
320    // immediately so reversing direction takes effect without delay.
321    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    // Derive fragmap available width from the final title_width so that the
333    // table constraints (SHA + title + fragmap) always sum to effective_width.
334    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        // Min(0) for title: SHA's Length(10) always wins over the title column
407        // so the SHA is never squished when the panel is narrow (e.g. the left
408        // sub-panel in CommitDetail view).
409        vec![Constraint::Length(10), Constraint::Min(0)]
410    }
411}
412
413/// Build all visible table rows.
414fn 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        // When a move separator row is visible it occupies one table row,
425        // so we show one fewer commit to keep everything within bounds.
426        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    // In SquashSelect mode, the source commit index drives candidate coloring.
447    let squash_source_idx = match app.mode {
448        AppMode::SquashSelect { source_index, .. } => Some(source_index),
449        _ => None,
450    };
451
452    // In MoveSelect mode, extract source and insertion point.
453    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        // Insert separator row before this commit if this is the insertion point.
476        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        // Determine text style based on mode and position.
492        let text_style = if let Some(source_idx) = squash_source_idx {
493            // SquashSelect mode: color by relation to squash source.
494            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            // Normal CommitList mode coloring for non-selected rows.
517            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        // Apply highlight: source gets teal bg, selection in an action mode gets
529        // a subtle target-bg tint plus reversed; plain selection gets reversed.
530        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    // Append separator after the last visible commit when moving to the end.
561    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
575/// Build the styled "▶ move here" separator row for MoveSelect mode.
576fn 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}