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::{
DEL, FILTER_KEYS, FIND, HELP, HelpSection, Modal, QUIT, REFRESH, 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);
}
}
let (keys, help) = match app.editing {
Some(_) => (FILTER_KEYS.to_vec(), HELP),
None => (actions(app), HELP),
};
frame.render_widget(
key_footer(
&keys,
app.note.as_ref(),
app.command.as_ref(),
Some(help),
footer_row.width,
),
footer_row,
);
if let Some(c) = &app.confirm
&& c.target.typed()
{
typed_popup(
frame,
panes,
&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,
panes,
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, panes);
}
}
fn actions(app: &App) -> Vec<&'static str> {
let mut keys = vec![match app.level {
Level::Commits => "↵ diff",
Level::Diff => "↵ difftool",
_ => "↵ open",
}];
let undo = |branch: bool| app.undo.as_ref().is_some_and(|u| u.branch == branch);
let mut del = false;
match app.level {
Level::Repos => keys.extend(["s sync all", "S pull all"]),
Level::Branches => {
keys.extend(["s sync", "S pull"]);
if undo(true) {
keys.push("u undo");
}
del = true;
}
Level::Tags => {
if app.can_track() {
keys.push("t track");
}
if undo(false) {
keys.push("u undo");
}
del = true;
}
Level::Commits if app.stashes => {
keys.extend(["a apply", "p pop"]);
del = true;
}
_ => {}
}
keys.push("K inspect");
if del {
keys.push(DEL);
}
if app.level != Level::Diff {
keys.push(FIND);
}
keys.extend([REFRESH, QUIT]);
keys
}
const MOVING: HelpSection = (
"moving",
&[
(Y_MOVE, "move a row"),
(X_MOVE, "the previous, next tab"),
("", "a held key speeds up"),
],
);
const MOVING_STASH: HelpSection = (
"moving",
&[(Y_MOVE, "move a row"), ("", "a held key speeds up")],
);
const BELOW: HelpSection = (
"the pane below",
&[(CTRL_Y_MOVE, "move in it"), ("ctrl-f", "find in it")],
);
const EVERYWHERE: HelpSection = (
"every screen",
&[
("↵", "open"),
("esc", "back"),
("/", "find"),
("K", "inspect the row"),
("r", "refresh"),
(":", "a command (:q quits)"),
("?", "this help"),
("q", "quit"),
("ctrl-c", "quit, or esc in a box or a filter"),
],
);
const REPOS: HelpSection = (
"repos",
&[
("s", "sync every repo (fetch, prune)"),
("S", "sync and pull"),
],
);
const BRANCHES: HelpSection = (
"branches",
&[
("s", "sync (fetch, prune)"),
("S", "sync and pull this one"),
("d", "delete, asking first"),
("u", "put back the last delete"),
],
);
const TAGS: HelpSection = (
"tags",
&[
("t", "keep a copy of the remote's tags"),
("d", "delete"),
("u", "put back the last delete"),
],
);
const COMMITS: HelpSection = ("commits", &[("↵", "the selected file's diff")]);
const STASHES: HelpSection = (
"stashes",
&[
("↵", "the selected file's diff"),
("a", "apply"),
("p", "pop"),
("d", "delete, asking its name first"),
],
);
const DIFF: HelpSection = (
"diff",
&[
("↵", "git difftool"),
(CTRL_Y_MOVE, "scroll"),
(CTRL_X_MOVE, "pan"),
],
);
const IN_A_BOX: HelpSection = (
"in a box",
&[
("y n", "answer"),
(X_MOVE, "pick"),
("↵", "select"),
(Y_MOVE, "scroll a long one"),
("esc", "cancel, or close a long one"),
],
);
const IN_A_FIND: HelpSection = (
"in a find",
&[
("type", "filter"),
("↵", "keep"),
("esc", "back"),
("?", "help"),
],
);
const IN_THIS_HELP: HelpSection = (
"in this help",
&[
(Y_MOVE, "scroll"),
("ctrl-d ctrl-u", "half a page down, up"),
("g G", "the top, the bottom"),
("esc q ?", "close"),
],
);
fn help(app: &App) -> Vec<HelpSection> {
let moving = match app.stashes {
true => MOVING_STASH,
false => MOVING,
};
let mut sections = vec![moving, BELOW, EVERYWHERE];
let reaches = |level: Level| match level {
Level::Tags => app.start == Level::Tags,
Level::Commits => !app.stashes,
level => app.start <= level,
};
if reaches(Level::Repos) {
sections.push(REPOS);
}
if reaches(Level::Branches) {
sections.push(BRANCHES);
}
if reaches(Level::Tags) {
sections.push(TAGS);
}
if reaches(Level::Commits) {
sections.push(COMMITS);
}
if app.stashes {
sections.push(STASHES);
}
sections.extend([DIFF, IN_A_BOX, IN_A_FIND, IN_THIS_HELP]);
sections
}
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(&mut self) {
self.modal = Some(Modal::help(&help(self)));
}
}