use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Clear, Paragraph, Wrap};
use super::list;
use crate::app::{App, PANELS};
use crate::git::rebase::{TodoAction, TodoItem};
use crate::git::{BranchEntry, CommitEntry};
fn mark_color(mark: char) -> Color {
match mark {
'A' => Color::Green,
'M' => Color::Yellow,
'D' => Color::Red,
'R' | 'C' => Color::Magenta,
'T' => Color::Cyan,
'U' => Color::LightRed,
_ => Color::DarkGray,
}
}
const LANE_COLORS: [Color; 6] =
[Color::Cyan, Color::Magenta, Color::Green, Color::Yellow, Color::Blue, Color::Red];
fn graph_spans(graph: &str, tagged: bool) -> Vec<Span<'static>> {
graph
.chars()
.enumerate()
.map(|(i, ch)| {
let lane = Style::new().fg(LANE_COLORS[(i / 2) % LANE_COLORS.len()]);
match ch {
'●' | '◉' if tagged => Span::styled(
"⬟".to_string(),
Style::new().fg(Color::LightYellow).add_modifier(Modifier::BOLD),
),
_ => Span::styled(ch.to_string(), lane),
}
})
.collect()
}
fn initials(author: &str) -> String {
let mut out = String::new();
for word in author.split_whitespace().take(2) {
if let Some(c) = word.chars().next() {
out.extend(c.to_uppercase());
}
}
if out.is_empty() { "??".into() } else { format!("{out:<2}") }
}
fn author_color(author: &str) -> Color {
const PALETTE: [Color; 6] = [
Color::LightMagenta,
Color::LightGreen,
Color::LightYellow,
Color::LightCyan,
Color::LightBlue,
Color::LightRed,
];
let sum: u32 = author.bytes().map(u32::from).sum();
PALETTE[sum as usize % PALETTE.len()]
}
fn commit_line(c: &CommitEntry, zoomed: bool, unpushed: bool, tags: &[String]) -> Line<'static> {
let mut spans = vec![
Span::styled(c.id_str().to_string(), Style::new().fg(Color::Yellow)),
Span::raw(" "),
Span::styled(initials(&c.author), Style::new().fg(author_color(&c.author))),
Span::raw(" "),
];
if zoomed {
spans.push(Span::styled(format!("{} ", ymd(c.time)), Style::new().fg(Color::DarkGray)));
}
spans.extend(graph_spans(&c.graph, !tags.is_empty()));
if unpushed {
spans.push(Span::styled("↑", Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD)));
}
for t in tags {
spans.push(Span::styled(
format!(" {t}"),
Style::new().fg(Color::LightYellow).add_modifier(Modifier::BOLD),
));
}
spans.push(Span::raw(format!(" {}", c.subject)));
Line::from(spans)
}
fn tags_of<'a>(app: &'a App, c: &CommitEntry) -> &'a [String] {
app.repo.tags.get(c.id_str()).map(Vec::as_slice).unwrap_or(&[])
}
fn ymd(secs: u32) -> String {
let z = (secs / 86400) as i64 + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = yoe + era * 400 + i64::from(m <= 2);
format!("{y:04}-{m:02}-{d:02}")
}
fn shorten(text: &str, max: usize) -> String {
if text.chars().count() <= max {
return text.to_string();
}
let keep = max.saturating_sub(1);
text.chars().take(keep).chain(std::iter::once('…')).collect()
}
fn branch_line(b: &BranchEntry, width: usize, spinner: Option<char>) -> Line<'static> {
let (icon, icon_color, name_style) = match (b.current, spinner) {
(true, Some(frame)) => (
frame.to_string(),
Color::Cyan,
Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD),
),
(true, None) => (
"*".to_string(),
Color::Green,
Style::new().fg(Color::Green).add_modifier(Modifier::BOLD),
),
_ => (" ".to_string(), Color::Green, Style::new()),
};
let mut tail: Vec<Span<'static>> = Vec::new();
if b.ahead > 0 {
tail.push(Span::styled(format!(" ↑{}", b.ahead), Style::new().fg(Color::Yellow)));
}
if b.behind > 0 {
tail.push(Span::styled(format!(" ↓{}", b.behind), Style::new().fg(Color::Magenta)));
}
if b.gone {
tail.push(Span::styled(" gone", Style::new().fg(Color::Red)));
}
let tail_width: usize = tail.iter().map(|s| s.content.chars().count()).sum();
let room = width.saturating_sub(7 + tail_width).max(3);
let name_style = if b.remote { Style::new().fg(Color::DarkGray) } else { name_style };
let mut spans = vec![
Span::styled(format!("{:>4} ", b.age), Style::new().fg(Color::Cyan)),
Span::styled(icon, Style::new().fg(icon_color)),
Span::styled(format!(" {}", shorten(&b.name, room)), name_style),
];
spans.extend(tail);
Line::from(spans)
}
fn tree_line(app: &App, i: usize) -> Line<'static> {
let row = &app.tree[i];
let pad = " ".repeat(row.depth as usize);
if let Some(dir) = &row.dir {
let arrow = if app.collapsed.contains(dir) { '▸' } else { '▾' };
let slash = if dir.is_empty() { "" } else { "/" };
return Line::styled(
format!(" {pad}{arrow} {}{slash}", row.name),
Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD),
);
}
let f = &app.repo.files[row.file.unwrap_or(0)];
let name_style = if f.conflicted() {
Style::new().fg(Color::LightRed).add_modifier(Modifier::BOLD | Modifier::REVERSED)
} else if f.staged() && f.work == ' ' {
Style::new().fg(Color::Green).add_modifier(Modifier::BOLD)
} else if f.staged() {
Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD)
} else if f.work == '?' {
Style::new().fg(Color::Red).add_modifier(Modifier::DIM)
} else {
Style::new().fg(Color::Red)
};
let mark = if app.marked.contains(&f.path) {
Span::styled("▌", Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD))
} else {
Span::raw(" ")
};
Line::from(vec![
mark,
Span::styled(
f.index.to_string(),
Style::new().fg(Color::Green).add_modifier(Modifier::BOLD),
),
Span::styled(f.work.to_string(), Style::new().fg(mark_color(f.work))),
Span::styled(format!(" {pad}{}", row.name), name_style),
])
}
pub fn render_left(frame: &mut Frame, areas: [Rect; 5], app: &App) {
let repo = &app.repo;
let branch_width = areas[2].width.saturating_sub(2) as usize;
let spinner = app.spinner();
let name = repo.head.clone().unwrap_or_else(|| "(no branch)".into());
let mut head = match &repo.upstream {
Some(up) => format!("{name} → {up}"),
None => format!("{name} (no upstream)"),
};
if repo.ahead > 0 {
head.push_str(&format!(" ↑{}", repo.ahead));
}
if repo.behind > 0 {
head.push_str(&format!(" ↓{}", repo.behind));
}
let banner = app
.rebase
.as_ref()
.map(|r| (format!("REBASE {}/{}", r.step, r.total), Color::Yellow))
.or_else(|| repo.bisecting.then(|| ("BISECT".to_string(), Color::Magenta)));
let rows: [&dyn Fn(usize) -> Line<'static>; 5] = [
&|_| match &banner {
Some((text, color)) => {
Line::styled(text.clone(), Style::new().fg(*color).add_modifier(Modifier::BOLD))
}
None => Line::styled(
head.clone(),
Style::new().fg(Color::Green).add_modifier(Modifier::BOLD),
),
},
&|i| tree_line(app, i),
&|i| branch_line(&repo.branches[i], branch_width, spinner),
&|i| {
let c = &repo.commits[i];
commit_line(c, false, repo.unpushed.contains(c.id_str()), tags_of(app, c))
},
&|i| {
let s = &repo.stashes[i];
match s.split_once(':') {
Some((name, rest)) => Line::from(vec![
Span::styled(name.to_string(), Style::new().fg(Color::Magenta)),
Span::raw(format!(":{rest}")),
]),
None => Line::from(s.clone()),
}
},
];
for (i, area) in areas.into_iter().enumerate() {
let title = match (i, &repo.filter, &repo.compare) {
(3, Some(text), _) => format!("[4]─Commits─search: {text}"),
(3, _, Some(id)) => format!("[4]─Commits─from {id}"),
(3, _, _) if app.first_parent => "[4]─Commits─main line".to_string(),
_ => format!("[{}]─{}", i + 1, PANELS[i]),
};
list::render(
frame,
area,
&title,
app.focus == i,
app.selected[i],
app.panel_len(i),
rows[i],
);
}
}
pub fn render_zoom(frame: &mut Frame, area: Rect, app: &App) {
let repo = &app.repo;
list::render(
frame,
area,
"Commits — enter: back, j/k, ctrl-d/u, g/G",
true,
app.selected[3],
app.panel_len(3),
&|i| {
let c = &repo.commits[i];
commit_line(c, true, repo.unpushed.contains(c.id_str()), tags_of(app, c))
},
);
}
pub fn render_rebase(frame: &mut Frame, area: Rect, items: &[TodoItem], cursor: usize) {
list::render(
frame,
area,
"Interactive rebase — enter: run, esc: cancel",
true,
cursor,
items.len(),
&|i| {
let it = &items[i];
let color = match it.action {
TodoAction::Pick => Color::Green,
TodoAction::Reword => Color::Cyan,
TodoAction::Edit => Color::Yellow,
TodoAction::Squash | TodoAction::Fixup => Color::Magenta,
TodoAction::Drop => Color::Red,
};
Line::from(vec![
Span::styled(
format!("{:<7}", it.action.word()),
Style::new().fg(color).add_modifier(Modifier::BOLD),
),
Span::styled(format!("{} ", it.id), Style::new().fg(Color::DarkGray)),
Span::raw(it.subject.clone()),
])
},
);
}
fn word_pair(old: &str, new: &str) -> Option<(Line<'static>, Line<'static>)> {
let (o_body, n_body) = (&old[1..], &new[1..]);
let s = super::words::changed_span(o_body, n_body)?;
let mark = Style::new().add_modifier(Modifier::REVERSED | Modifier::BOLD);
let build = |sign: &str, body: &str, (a, b): (usize, usize), color: Color| {
Line::from(vec![
Span::styled(format!("{sign}{}", &body[..a]), Style::new().fg(color)),
Span::styled(body[a..b].to_string(), mark.fg(color)),
Span::styled(body[b..].to_string(), Style::new().fg(color)),
])
};
Some((build("-", o_body, s.old, Color::Red), build("+", n_body, s.new, Color::Green)))
}
fn diff_line(l: &str) -> Line<'static> {
let style = if l.starts_with("diff ")
|| l.starts_with("index ")
|| l.starts_with("--- ")
|| l.starts_with("+++ ")
{
Style::new().add_modifier(Modifier::BOLD)
} else if l.starts_with("commit ")
|| l.starts_with("Author")
|| l.starts_with("Date")
|| l.starts_with("Merge")
{
Style::new().fg(Color::Yellow)
} else {
match l.as_bytes().first() {
Some(b'+') => Style::new().fg(Color::Green),
Some(b'-') => Style::new().fg(Color::Red),
Some(b'@') => Style::new().fg(Color::Cyan),
_ => Style::new(),
}
};
Line::styled(l.to_string(), style)
}
fn render_hunk(
frame: &mut Frame,
area: Rect,
path: &str,
hunks: &[String],
cursor: usize,
line: usize,
picked: &[usize],
) {
let body: Vec<&str> = hunks[cursor].lines().skip(1).collect();
let title = format!("Hunk {}/{} — {path}", cursor + 1, hunks.len());
list::render(frame, area, &title, true, line, body.len(), &|i| {
let text = body[i];
let style = match text.as_bytes().first() {
Some(b'+') => Style::new().fg(Color::Green),
Some(b'-') => Style::new().fg(Color::Red),
_ => Style::new().fg(Color::Gray),
};
let gutter = if picked.contains(&i) {
Span::styled("▌", Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD))
} else {
Span::raw(" ")
};
Line::from(vec![gutter, Span::styled(text.to_string(), style)])
});
}
pub fn render_main(frame: &mut Frame, area: Rect, app: &App) {
match &app.mode {
crate::app::Mode::Rebase { items, cursor, .. } => {
return render_rebase(frame, area, items, *cursor);
}
crate::app::Mode::Hunks { path, hunks, cursor, line, picked, .. } => {
return render_hunk(frame, area, path, hunks, *cursor, *line, picked);
}
_ => {}
}
let both = !app.repo.diff_staged.is_empty() && !app.repo.diff.is_empty();
let title = match (app.focus, both) {
(3, _) => "[0]─Commit",
(_, true) => "[0]─Changes",
_ if !app.repo.diff_staged.is_empty() => "[0]─Staged changes",
_ => "[0]─Unstaged changes",
};
let mut rows: Vec<Line> = Vec::new();
let width = area.width.saturating_sub(2) as usize;
let limit = app.diff_scroll as usize + area.height as usize * 4;
for (name, text, color) in [
("Unstaged changes", &app.repo.diff, Color::Red),
("Staged changes", &app.repo.diff_staged, Color::Green),
] {
if text.is_empty() {
continue;
}
if both {
let bar = "─".repeat(width.saturating_sub(name.len() + 4));
rows.push(Line::styled(
format!("── {name} {bar}"),
Style::new().fg(color).add_modifier(Modifier::BOLD),
));
}
let src: Vec<&str> = text.lines().collect();
let mut i = 0;
while i < src.len() {
let pair = match src.get(i + 1) {
Some(next)
if src[i].starts_with('-')
&& !src[i].starts_with("---")
&& next.starts_with('+')
&& !next.starts_with("+++") =>
{
word_pair(src[i], next)
}
_ => None,
};
match pair {
Some((a, b)) => {
rows.push(a);
rows.push(b);
i += 2;
}
None => {
rows.push(diff_line(src[i]));
i += 1;
}
}
if rows.len() >= limit {
break;
}
}
if rows.len() >= limit {
break;
}
}
let lines: Vec<Line> = rows.into_iter().skip(app.diff_scroll as usize).collect();
let border = if app.focus == 5 { Color::Green } else { Color::DarkGray };
frame.render_widget(
Paragraph::new(lines).wrap(Wrap { trim: false }).block(
Block::bordered()
.title(Span::styled(
title.to_string(),
Style::new().fg(border).add_modifier(Modifier::BOLD),
))
.border_style(Style::new().fg(border)),
),
area,
);
}
pub fn render_worktrees(
frame: &mut Frame,
area: Rect,
list: &[crate::git::WorktreeEntry],
cursor: usize,
home: &str,
) {
frame.render_widget(Clear, area);
let width = area.width.saturating_sub(2) as usize;
list::render(frame, area, " Worktrees ", true, cursor, list.len(), &|i| {
let w = &list[i];
let mut tag = String::new();
if w.main {
tag.push_str(" main");
}
if w.locked {
tag.push_str(" locked");
}
if w.prunable {
tag.push_str(" gone");
}
let path = match (!home.is_empty()).then(|| w.path.strip_prefix(home)).flatten() {
Some(rest) => format!("~{rest}"),
None => w.path.clone(),
};
let branch_col = 22.min(width / 3);
let room = width.saturating_sub(2 + branch_col + tag.chars().count() + 1).max(6);
let (icon, branch_style) = if w.current {
("●", Style::new().fg(Color::Green).add_modifier(Modifier::BOLD))
} else {
(" ", Style::new().fg(Color::Cyan))
};
Line::from(vec![
Span::styled(icon, Style::new().fg(Color::Green)),
Span::styled(format!(" {:<w$.w$}", w.branch, w = branch_col), branch_style),
Span::styled(shorten(&path, room), Style::new().fg(Color::Gray)),
Span::styled(
tag,
Style::new().fg(if w.prunable { Color::Red } else { Color::DarkGray }),
),
])
});
}
pub fn render_blame(
frame: &mut Frame,
area: Rect,
path: &str,
lines: &[crate::git::BlameLine],
cursor: usize,
) {
frame.render_widget(Clear, area);
let here = lines.get(cursor).map(|l| l.id.clone()).unwrap_or_default();
let title = format!(" Blame — {path} ");
list::render(frame, area, &title, true, cursor, lines.len(), &|i| {
let l = &lines[i];
let same = l.id == here;
let meta = if same { Color::LightYellow } else { Color::DarkGray };
Line::from(vec![
Span::styled(format!("{} ", l.id), Style::new().fg(meta)),
Span::styled(format!("{} ", l.date), Style::new().fg(meta)),
Span::styled(format!("{:<12.12} ", l.author), Style::new().fg(author_color(&l.author))),
Span::styled(l.text.clone(), Style::new().fg(Color::Gray)),
])
});
}
pub fn render_submodules(
frame: &mut Frame,
area: Rect,
list: &[crate::git::SubmoduleEntry],
cursor: usize,
) {
frame.render_widget(Clear, area);
list::render(frame, area, " Submodules ", true, cursor, list.len(), &|i| {
let s = &list[i];
let (tag, color) = match s.state {
'-' => (" not there", Color::Red),
'+' => (" other commit", Color::Yellow),
'U' => (" conflict", Color::LightRed),
_ => ("", Color::Green),
};
Line::from(vec![
Span::styled(format!("{} ", s.id), Style::new().fg(Color::DarkGray)),
Span::styled(s.path.clone(), Style::new().fg(Color::Cyan)),
Span::styled(tag.to_string(), Style::new().fg(color)),
])
});
}
pub fn render_reflog(
frame: &mut Frame,
area: Rect,
list: &[crate::git::ReflogEntry],
cursor: usize,
) {
frame.render_widget(Clear, area);
let width = area.width.saturating_sub(2) as usize;
list::render(frame, area, " Where HEAD has been ", true, cursor, list.len(), &|i| {
let e = &list[i];
let room = width.saturating_sub(e.id.len() + e.at.len() + 3).max(6);
Line::from(vec![
Span::styled(format!("{} ", e.id), Style::new().fg(Color::Yellow)),
Span::styled(format!("{:<14.14} ", e.at), Style::new().fg(Color::Cyan)),
Span::styled(shorten(&e.what, room), Style::new().fg(Color::Gray)),
])
});
}
pub fn render_log(frame: &mut Frame, area: Rect, app: &App) {
let take = area.height.saturating_sub(2) as usize;
let width = area.width.saturating_sub(2).max(1) as usize;
let height = |line: &Line| line.width().div_ceil(width).max(1);
let rows = |e: &crate::app::LogEntry| {
let (icon, color) = if e.ok { ("✓", Color::Green) } else { ("✗", Color::Red) };
let time_color = if e.ms >= 500 { Color::Yellow } else { Color::DarkGray };
let mut out = vec![Line::from(vec![
Span::styled(format!("{icon} "), Style::new().fg(color)),
Span::styled(
e.cmd.clone(),
Style::new().fg(if e.ok { Color::Gray } else { Color::Red }),
),
Span::styled(format!(" {}ms", e.ms), Style::new().fg(time_color)),
])];
let color = if e.ok { Color::DarkGray } else { Color::Red };
for line in &e.output {
out.push(Line::styled(format!(" {line}"), Style::new().fg(color)));
}
out
};
let mut lines: Vec<Line> = Vec::new();
let mut used = 0usize;
for e in app.cmd_log.iter().rev() {
let mut r = rows(e);
let need: usize = r.iter().map(&height).sum();
if used + need > take {
if lines.is_empty() {
let mut fit = 0;
while fit < r.len() && used + height(&r[fit]) <= take {
used += height(&r[fit]);
fit += 1;
}
r.truncate(fit.max(1));
lines = r;
}
break;
}
used += need;
r.extend(lines);
lines = r;
}
frame.render_widget(
Paragraph::new(lines).wrap(Wrap { trim: false }).block(
Block::bordered()
.title(Span::styled("[@]─Command log", Style::new().fg(Color::DarkGray)))
.border_style(Style::new().fg(Color::DarkGray)),
),
area,
);
}