use super::{App, Level, Pane, Sel, branches, commits, repos, tags};
use crate::git::RepoStatus;
use crate::git::load::{RemoteTags, Tag, TagState};
use crate::tui::input::{CTRL_X_MOVE, CTRL_Y_MOVE, X_MOVE, Y_MOVE};
use crate::tui::widgets::{
commit_item, confirm_popup, diff_hscrollbar, diff_scrollbar, file_item, key_footer,
list_scrollbar, pane_block, scope_tabs, typed_popup,
};
use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{List, ListItem, Paragraph};
const NAME_W: usize = 28;
const REPO_NAME_MAX: usize = 40;
const ORIGIN_MAX: usize = 38;
pub(super) fn draw(frame: &mut Frame, app: &mut App) {
let [panes, footer_row] =
Layout::vertical([Constraint::Min(2), Constraint::Length(1)]).areas(frame.area());
let [top, bottom] =
Layout::vertical([Constraint::Percentage(40), Constraint::Percentage(60)]).areas(panes);
let (edit_top, edit_bot) = (
app.editing == Some(Pane::Top),
app.editing == Some(Pane::Bottom),
);
match app.level {
Level::Repos => {
let labels = stops(&repos::SCOPES, repos::Scope::label);
let scope = Some((labels.as_slice(), app.rsel.scope));
let items = repo_items(app);
let top_title = title("repos", scope, &app.rsel, false, Pane::Top, edit_top, true);
list(frame, top, items, top_title, true, &mut app.rsel);
let items = branch_items(app);
let slow = app.bfeed.slow();
let bot_title = title(
"branches",
None,
&app.bsel,
slow,
Pane::Bottom,
edit_bot,
true,
);
list(frame, bottom, items, bot_title, true, &mut app.bsel);
}
Level::Branches => {
let labels = stops(&branches::SCOPES, branches::Scope::label);
let scope = Some((labels.as_slice(), app.bsel.scope));
let items = branch_items(app);
let slow = app.bfeed.slow();
let top_title = title(
"branches",
scope,
&app.bsel,
slow,
Pane::Top,
edit_top,
true,
);
list(frame, top, items, top_title, true, &mut app.bsel);
let items = commit_items(app, bottom.width);
let slow = app.cfeed.slow();
let bot_title = title(
"commits",
None,
&app.csel,
slow,
Pane::Bottom,
edit_bot,
true,
);
list(frame, bottom, items, bot_title, true, &mut app.csel);
}
Level::Tags => {
let labels = stops(&tags::SCOPES, tags::Scope::label);
let scope = Some((labels.as_slice(), app.tsel.scope));
let items = tag_items(app);
let slow = app.rfeed.slow();
let (name, status) = tags_label(app);
let mut top_title = title(&name, scope, &app.tsel, slow, Pane::Top, edit_top, true);
if let Some(status) = status {
let at = top_title.spans.len() - 1;
let dim = Style::default().add_modifier(Modifier::DIM);
top_title
.spans
.insert(at, Span::styled(format!(" {status}"), dim));
}
list(frame, top, items, top_title, true, &mut app.tsel);
let items = commit_items(app, bottom.width);
let unfetched = app.csel.is_empty()
&& !app.cfeed.loading
&& app
.tsel
.idx()
.is_some_and(|i| app.tags[i].state == TagState::Remote);
let name = if unfetched {
"commits · not in this clone until a fetch".to_string()
} else {
app.range_label()
};
let slow = app.cfeed.slow();
let bot_title = title(&name, None, &app.csel, slow, Pane::Bottom, edit_bot, true);
list(frame, bottom, items, bot_title, true, &mut app.csel);
}
Level::Commits => {
let labels = stops(&commits::SCOPES, commits::Scope::label);
let scope = (!app.stashes).then_some((labels.as_slice(), app.csel.scope));
let items = commit_items(app, top.width);
let slow = app.cfeed.slow();
let top_title = title(
&commits_label(app),
scope,
&app.csel,
slow,
Pane::Top,
edit_top,
true,
);
list(frame, top, items, top_title, true, &mut app.csel);
let items = file_items(app);
let slow = app.ffeed.slow();
let bot_title = title("files", None, &app.fsel, slow, Pane::Bottom, edit_bot, true);
list(frame, bottom, items, bot_title, true, &mut app.fsel);
}
Level::Diff => {
let labels = stops(&commits::SCOPES, commits::Scope::label);
let scope = (!app.stashes).then_some((labels.as_slice(), app.csel.scope));
let items = commit_items(app, top.width);
let slow = app.cfeed.slow();
let top_title = title(
&commits_label(app),
scope,
&app.csel,
slow,
Pane::Top,
false,
false,
);
list(frame, top, items, top_title, false, &mut app.csel);
app.diff_rows = bottom.height.saturating_sub(2).max(1);
diff(frame, bottom, app);
}
}
frame.render_widget(
key_footer(
&actions(app),
app.note.as_ref(),
app.command.as_ref(),
true,
footer_row.width,
),
footer_row,
);
if let Some(c) = &app.confirm
&& c.target.typed()
{
typed_popup(
frame,
&c.target.title(),
c.target.note().as_deref(),
&c.target.name(),
&c.typed,
c.target.verb(),
);
} else if let Some(c) = &app.confirm {
confirm_popup(
frame,
c.target.colour(),
&c.target.title(),
&c.target.name(),
c.target.note().as_deref(),
c.yes,
);
}
if let Some(modal) = &mut app.modal {
modal.draw(frame);
}
}
fn actions(app: &App) -> Vec<String> {
let mut keys = vec![match app.level {
Level::Commits => "enter diff".to_string(),
Level::Diff => "enter difftool".to_string(),
_ => "enter open".to_string(),
}];
let undo = |branch: bool| app.undo.as_ref().is_some_and(|u| u.branch == branch);
match app.level {
Level::Repos => keys.extend(["s sync all".to_string(), "S pull all".to_string()]),
Level::Branches => {
keys.push("d delete branch".to_string());
if undo(true) {
keys.push("u undo".to_string());
}
keys.extend(["s sync".to_string(), "S pull".to_string()]);
}
Level::Tags => {
keys.push("d delete tag".to_string());
if undo(false) {
keys.push("u undo".to_string());
}
if app.can_track() {
keys.push("t track".to_string());
}
}
Level::Commits if app.stashes => keys.extend([
"a apply".to_string(),
"p pop".to_string(),
"d drop".to_string(),
]),
_ => {}
}
keys.push("K inspect".to_string());
keys.push("r refresh".to_string());
keys
}
fn help(app: &App) -> Vec<(String, String)> {
let row = |k: &str, d: &str| (k.to_string(), d.to_string());
let below = match app.level {
Level::Repos => "branches",
Level::Branches | Level::Tags => "commits",
_ => "files",
};
if app.level == Level::Diff {
return vec![
row(CTRL_Y_MOVE, "scroll the diff"),
row(CTRL_X_MOVE, "pan it sideways"),
row("enter", "open the file in your git difftool"),
row("K", "everything about the commit"),
row("r", "read the commits again and reopen this file"),
row("esc", "back to the commits"),
row("q", "quit"),
];
}
let mut rows = vec![row(Y_MOVE, "move (hold to speed up)")];
if !app.stashes {
rows.push(row(X_MOVE, "switch tab"));
}
rows.extend([
(
CTRL_Y_MOVE.to_string(),
format!("move in the {below} below"),
),
("/ ?".to_string(), format!("filter this list / the {below}")),
row(
"enter",
if app.level == Level::Commits {
"open the file's diff"
} else {
"open it"
},
),
]);
match app.level {
Level::Repos => rows.extend([
row("s", "sync every repo listed: fetch and prune"),
row("S", "sync them and pull each checked-out branch"),
]),
Level::Branches => rows.extend([
row("d", "delete branch (asks first)"),
row("u", "put back the last delete"),
row("s", "sync: fetch and prune"),
row("S", "sync and pull the checked-out branch"),
]),
Level::Tags => rows.extend([
row("d", "delete tag (asks first)"),
row("u", "put back the last delete"),
row("t", "show push marks instantly"),
]),
Level::Commits if app.stashes => rows.extend([
row("a", "apply the stash, keeping it"),
row("p", "pop it: apply, then drop it"),
row("d", "drop it (asks first)"),
]),
_ => {}
}
rows.extend([
row("K", "everything about the row under the cursor"),
row("r", "read it again from git"),
row("esc", "back"),
row("q", "quit"),
]);
rows
}
fn list(
frame: &mut Frame,
area: Rect,
items: Vec<ListItem<'static>>,
title: Line<'static>,
active: bool,
sel: &mut Sel,
) {
let widget = List::new(items)
.block(pane_block(title, active))
.highlight_style(Style::default().add_modifier(Modifier::REVERSED))
.highlight_symbol("› ");
frame.render_stateful_widget(widget, area, &mut sel.state);
list_scrollbar(frame, area, sel.len(), sel.state.offset());
}
fn diff(frame: &mut Frame, area: Rect, app: &App) {
let title = format!(" {} ", app.file_path().unwrap_or(""));
let body = if app.diff.lines.is_empty() && app.dfeed.slow() {
Text::from(Line::from(Span::styled(
" loading…",
Style::default().add_modifier(Modifier::DIM),
)))
} else {
app.diff.clone()
};
let view = Paragraph::new(body)
.block(pane_block(title, true))
.scroll((app.diff_scroll, 0));
frame.render_widget(view, area);
diff_scrollbar(frame, area, app.diff.lines.len(), app.diff_scroll);
let cell = app.prepared.cell_width(area.width.saturating_sub(2));
diff_hscrollbar(frame, area, app.prepared.max_line(), cell, app.diff_hscroll);
}
fn title(
name: &str,
scope: Option<(&[&str], usize)>,
sel: &Sel,
loading: bool,
pane: Pane,
editing: bool,
filterable: bool,
) -> Line<'static> {
let mut spans = vec![Span::raw(format!(" {name} "))];
if let Some((labels, picked)) = scope {
spans.extend(scope_tabs(labels, picked));
}
spans.push(Span::raw(" "));
let count = if sel.is_empty() {
let body = if loading { "…" } else { "(none)" };
body.to_string()
} else {
let dots = if loading { "…" } else { "" };
format!("{}/{}{dots}", sel.cur + 1, sel.len())
};
spans.push(Span::raw(count));
spans.extend(sel.query.title_span(pane.sigil(), editing, filterable));
spans.push(Span::raw(" "));
Line::from(spans)
}
fn stops<T: Copy>(scopes: &[T], label: fn(T) -> &'static str) -> Vec<&'static str> {
scopes.iter().map(|&s| label(s)).collect()
}
fn tags_label(app: &App) -> (String, Option<String>) {
let name = match &app.tag_remote {
Some(remote) => format!("tags vs {remote}"),
None => "tags".to_string(),
};
let status = match &app.remote {
None => Some("asking the remote…".to_string()),
Some(RemoteTags::NoRemote) => Some("no remote".to_string()),
Some(RemoteTags::Unreachable(_)) => Some("unreachable".to_string()),
Some(RemoteTags::Answered { fresh: true, .. }) => None,
Some(RemoteTags::Answered { .. }) if app.rfeed.loading => Some("refreshing…".to_string()),
Some(RemoteTags::Answered { .. }) => Some("as of last fetch".to_string()),
};
(name, status)
}
fn commits_label(app: &App) -> String {
if app.stashes {
"stashes".to_string()
} else if app.start == Level::Tags {
app.range_label()
} else if app.paths.is_empty() {
"commits".to_string()
} else {
format!("commits · {}", app.paths.join(" "))
}
}
fn repo_items(app: &App) -> Vec<ListItem<'static>> {
let name_w = app
.rsel
.visible
.iter()
.map(|&i| app.repos[i].name.chars().count())
.max()
.unwrap_or(0)
.clamp(1, REPO_NAME_MAX);
let branch_w = app
.rsel
.visible
.iter()
.map(|&i| app.repos[i].branch.chars().count())
.max()
.unwrap_or(0)
.min(24);
let state_w = app
.rsel
.visible
.iter()
.map(|&i| state_width(&app.repos[i]))
.max()
.unwrap_or(0);
app.rsel
.visible
.iter()
.map(|&i| repo_item(&app.repos[i], name_w, branch_w, state_w))
.collect()
}
fn state_width(r: &RepoStatus) -> usize {
state_spans(r)
.iter()
.map(|s| s.content.chars().count())
.sum()
}
fn state_spans(r: &RepoStatus) -> Vec<Span<'static>> {
if !r.needs_attention() {
return if r.has_upstream {
vec![Span::styled("✓ clean", Style::default().fg(Color::Green))]
} else {
vec![Span::styled(
"✓ clean (no upstream)",
Style::default().add_modifier(Modifier::DIM),
)]
};
}
let mut spans = Vec::new();
if r.dirty > 0 {
spans.push(Span::styled(
format!("✚{}", r.dirty),
Style::default().fg(Color::Yellow),
));
}
if r.ahead > 0 {
spans.push(Span::styled(
format!("↑{}", r.ahead),
Style::default().fg(Color::Green),
));
}
if r.behind > 0 {
spans.push(Span::styled(
format!("↓{}", r.behind),
Style::default().fg(Color::Red),
));
}
let last = spans.len().saturating_sub(1);
for (i, span) in spans.iter_mut().enumerate() {
if i != last {
*span = Span::styled(format!("{} ", span.content), span.style);
}
}
spans
}
fn repo_item(r: &RepoStatus, name_w: usize, branch_w: usize, state_w: usize) -> ListItem<'static> {
let mut spans = vec![
Span::styled(
format!(" {:<name_w$}", truncate(&r.name, name_w)),
Style::default().add_modifier(Modifier::BOLD),
),
Span::styled(
format!(" {:<branch_w$}", truncate(&r.branch, branch_w)),
Style::default().fg(Color::Cyan),
),
Span::raw(" "),
];
let state = state_spans(r);
let pad = state_w.saturating_sub(state.iter().map(|s| s.content.chars().count()).sum());
spans.extend(state);
spans.push(Span::raw(" ".repeat(pad)));
if !r.origin.is_empty() {
spans.push(Span::styled(
format!(" {}", truncate(&r.origin, ORIGIN_MAX)),
Style::default().fg(Color::DarkGray),
));
}
ListItem::new(Line::from(spans))
}
fn branch_items(app: &App) -> Vec<ListItem<'static>> {
if app.bsel.is_empty() && app.bfeed.slow() {
return loading_body();
}
app.bsel
.visible
.iter()
.map(|&i| branch_item(&app.branches[i]))
.collect()
}
fn branch_item(b: &branches::Branch) -> ListItem<'static> {
let name_style = if b.is_head {
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD)
} else if b.remote {
Style::default().fg(Color::Magenta)
} else {
Style::default().fg(Color::Cyan)
};
let (mark, mark_style) = branch_mark(b);
ListItem::new(Line::from(vec![
Span::raw(if b.is_head { "● " } else { " " }),
cell(format!("{mark:<2}"), mark_style),
Span::styled(
format!("{:<NAME_W$}", truncate(&b.name, NAME_W)),
name_style,
),
cell(format!(" {:<10}", b.status()), mark_style),
Span::styled(
format!(" {:<14}", b.rel),
Style::default().fg(Color::Magenta),
),
Span::styled(format!(" {}", b.author), Style::default().fg(Color::Blue)),
]))
}
fn branch_mark(b: &branches::Branch) -> (&'static str, Style) {
if b.remote {
return ("", Style::default().fg(Color::DarkGray));
}
if b.track.contains("gone") {
return ("⚑", Style::default().fg(Color::Red));
}
if b.unpushed() {
return (
"↑",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
);
}
("", Style::default().fg(Color::DarkGray))
}
fn tag_items(app: &App) -> Vec<ListItem<'static>> {
let name_w = app
.tsel
.visible
.iter()
.map(|&i| app.tags[i].name.chars().count())
.max()
.unwrap_or(0)
.clamp(1, NAME_W);
let asking = app.remote.is_none();
app.tsel
.visible
.iter()
.map(|&i| tag_item(&app.tags[i], name_w, asking))
.collect()
}
fn tag_item(t: &Tag, name_w: usize, asking: bool) -> ListItem<'static> {
let (mark, mark_style) = tag_mark(t.state);
let name_color = if t.state == TagState::Remote {
Color::Magenta
} else {
Color::Cyan
};
let kind = if t.annotated {
"annotated"
} else {
"lightweight"
};
ListItem::new(Line::from(vec![
Span::raw(" "),
cell(format!("{mark:<2}"), mark_style),
Span::styled(
format!("{:<name_w$}", truncate(&t.name, name_w)),
Style::default().fg(name_color),
),
cell(
format!(" {:<16}", t.date),
Style::default().fg(Color::Green),
),
state_cell(t.state, asking, mark_style),
Span::styled(
format!(" {kind:<11}"),
Style::default().add_modifier(Modifier::DIM),
),
Span::raw(format!(" {}", t.subject)),
]))
}
fn state_cell(state: TagState, asking: bool, style: Style) -> Span<'static> {
if asking && state == TagState::Unknown {
return Span::styled(
format!(" {:<11}", "checking…"),
Style::default().add_modifier(Modifier::DIM),
);
}
Span::styled(format!(" {:<11}", state.label()), style)
}
fn tag_mark(state: TagState) -> (&'static str, Style) {
match state {
TagState::Local => (
"↑",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
TagState::Differs => ("⚑", Style::default().fg(Color::Red)),
TagState::Remote => ("↓", Style::default().fg(Color::Magenta)),
TagState::Pushed | TagState::Unknown => ("", Style::default().fg(Color::DarkGray)),
}
}
fn loading_body() -> Vec<ListItem<'static>> {
vec![ListItem::new(Line::from(Span::styled(
" loading…",
Style::default().add_modifier(Modifier::DIM),
)))]
}
fn commit_items(app: &App, width: u16) -> Vec<ListItem<'static>> {
if app.csel.is_empty() && app.cfeed.slow() {
return loading_body();
}
let row = (width as usize).saturating_sub(4);
app.csel
.visible
.iter()
.map(|&i| {
let c = &app.commits[i];
commit_item(c, app.unpushed.contains(&c.hash), row)
})
.collect()
}
fn file_items(app: &App) -> Vec<ListItem<'static>> {
if app.fsel.is_empty() && app.ffeed.slow() {
return loading_body();
}
app.fsel
.visible
.iter()
.map(|&i| file_item(&app.files[i]))
.collect()
}
fn cell(text: String, style: Style) -> Span<'static> {
if text.trim().is_empty() {
Span::raw(text)
} else {
Span::styled(text, style)
}
}
fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max {
return s.to_string();
}
let kept: String = s.chars().take(max.saturating_sub(1)).collect();
format!("{kept}…")
}
impl App {
pub(super) fn help_rows(&self) -> Vec<(String, String)> {
help(self)
}
pub(super) fn help_title(&self) -> String {
let screen = match self.level {
Level::Repos => "repos",
Level::Branches => "branches",
Level::Tags => "tags",
Level::Commits if self.stashes => "stashes",
Level::Commits => "commits",
Level::Diff => "diff",
};
format!("keys · {screen}")
}
}