use crate::core::repo::{
CommitInfo, ContextCommit, FileChange, RemoteStatus, RepoInfo, UpstreamInfo,
};
use crate::core::shortid::IdAllocator;
use colored::{Color, Colorize};
use std::collections::{HashMap, HashSet};
use std::fmt::Write;
use terminal_size::{Width, terminal_size};
pub struct Theme {
pub graph: Color,
pub branch: Color,
pub label: Color,
pub dim: Color,
pub message: Color,
pub shortid: Color,
pub staged: Color,
pub unstaged: Color,
pub untracked: Color,
pub remote_synced: Color,
pub remote_ahead: Color,
pub remote_gone: Color,
pub conflict: Color,
pub branch_dots: &'static [Color],
}
impl Theme {
pub fn dark() -> Self {
Theme {
graph: Color::BrightBlack,
branch: Color::Green,
label: Color::Cyan,
dim: Color::AnsiColor(240),
message: Color::AnsiColor(248),
shortid: Color::Blue,
staged: Color::Green,
unstaged: Color::Red,
untracked: Color::Magenta,
remote_synced: Color::Green,
remote_ahead: Color::Yellow,
remote_gone: Color::Red,
conflict: Color::Red,
branch_dots: BRANCH_DOTS,
}
}
pub fn light() -> Self {
Theme {
graph: Color::AnsiColor(248),
branch: Color::Green,
label: Color::Blue,
dim: Color::AnsiColor(248),
message: Color::AnsiColor(243),
shortid: Color::Blue,
staged: Color::Green,
unstaged: Color::Red,
untracked: Color::Magenta,
remote_synced: Color::Green,
remote_ahead: Color::Yellow,
remote_gone: Color::Red,
conflict: Color::Red,
branch_dots: BRANCH_DOTS,
}
}
}
const BRANCH_DOTS: &[Color] = &[
Color::Yellow,
Color::Cyan,
Color::Magenta,
Color::Blue,
Color::Red,
Color::Green,
];
const UNTRACKED_MULTICOLUMN_THRESHOLD: usize = 5;
const GRAPH_PREFIX_WIDTH: usize = 4;
pub struct RenderOpts {
pub terminal_width: Option<u16>,
pub theme: Theme,
pub cwd_prefix: String,
}
enum Section {
WorkingChanges(Vec<FileChange>),
Branch {
names: Vec<(String, Option<RemoteStatus>)>,
commits: Vec<CommitInfo>,
},
Loose(Vec<CommitInfo>),
Upstream(UpstreamInfo),
Context(Vec<ContextCommit>),
}
pub fn render(info: RepoInfo, ids: &IdAllocator, opts: &RenderOpts) -> String {
let sections = build_sections(info);
render_sections(§ions, ids, opts)
}
pub fn default_render_opts(theme: Theme, cwd_prefix: String) -> RenderOpts {
RenderOpts {
terminal_width: terminal_size().map(|(Width(w), _)| w),
theme,
cwd_prefix,
}
}
fn display_path(repo_path: &str, cwd_prefix: &str) -> String {
crate::core::repo::cwd_relative_path(repo_path, cwd_prefix)
}
fn build_sections(info: RepoInfo) -> Vec<Section> {
let branch_tip_set: HashSet<git2::Oid> = info.branches.iter().map(|b| b.tip_oid).collect();
let mut tip_to_names: HashMap<git2::Oid, Vec<(String, Option<RemoteStatus>)>> = HashMap::new();
for b in &info.branches {
tip_to_names
.entry(b.tip_oid)
.or_default()
.push((b.name.clone(), b.remote.clone()));
}
let parent_map: HashMap<git2::Oid, Option<git2::Oid>> =
info.commits.iter().map(|c| (c.oid, c.parent_oid)).collect();
let mut commit_to_branch: HashMap<git2::Oid, String> = HashMap::new();
let mut seen_tips: HashSet<git2::Oid> = HashSet::new();
for b in &info.branches {
if !seen_tips.insert(b.tip_oid) {
continue; }
let canonical_name = tip_to_names[&b.tip_oid][0].0.clone();
let mut current = Some(b.tip_oid);
let mut is_tip = true;
while let Some(oid) = current {
if !parent_map.contains_key(&oid) {
break; }
if !is_tip && branch_tip_set.contains(&oid) {
break;
}
is_tip = false;
commit_to_branch.insert(oid, canonical_name.clone());
current = parent_map.get(&oid).and_then(|p| *p);
}
}
let mut canonical_to_names: HashMap<String, Vec<(String, Option<RemoteStatus>)>> =
HashMap::new();
for branches in tip_to_names.values() {
let mut reversed = branches.clone();
reversed.reverse();
canonical_to_names.insert(branches[0].0.clone(), reversed);
}
let mut sections: Vec<Section> = Vec::new();
sections.push(Section::WorkingChanges(info.working_changes));
let mut loose_commits: Vec<CommitInfo> = Vec::new();
let mut branch_sections: Vec<Section> = Vec::new();
let mut commits = info.commits.into_iter().peekable();
while let Some(commit) = commits.next() {
if let Some(branch_name) = commit_to_branch.get(&commit.oid) {
let name = branch_name.clone();
let names = canonical_to_names
.get(&name)
.cloned()
.unwrap_or_else(|| vec![(name.clone(), None)]);
let mut branch_commits = vec![commit];
while let Some(next) = commits.peek() {
if commit_to_branch.get(&next.oid) == Some(&name) {
branch_commits.push(commits.next().unwrap());
} else {
break;
}
}
branch_sections.push(Section::Branch {
names,
commits: branch_commits,
});
} else {
loose_commits.push(commit);
while let Some(next) = commits.peek() {
if commit_to_branch.contains_key(&next.oid) {
break;
}
loose_commits.push(commits.next().unwrap());
}
}
}
let represented: HashSet<&String> = commit_to_branch.values().collect();
for branches in canonical_to_names.values() {
if !branches.iter().any(|(n, _)| represented.contains(n)) {
branch_sections.push(Section::Branch {
names: branches.clone(),
commits: vec![],
});
}
}
if !loose_commits.is_empty() {
sections.push(Section::Loose(loose_commits));
}
sections.extend(branch_sections);
sections.push(Section::Upstream(info.upstream));
if !info.context_commits.is_empty() {
sections.push(Section::Context(info.context_commits));
}
sections
}
fn is_stacked_with_next(sections: &[Section], idx: usize) -> bool {
let Section::Branch { commits, .. } = §ions[idx] else {
return false;
};
let Some(Section::Branch {
commits: next_commits,
..
}) = sections.get(idx + 1)
else {
return false;
};
let Some(last) = commits.last() else {
return false;
};
let Some(next_first) = next_commits.first() else {
return false;
};
last.parent_oid == Some(next_first.oid)
}
fn render_sections(sections: &[Section], ids: &IdAllocator, opts: &RenderOpts) -> String {
let mut out = String::new();
let last_idx = sections.len() - 1;
let mut branch_color_idx: usize = 0;
for (idx, section) in sections.iter().enumerate() {
match section {
Section::WorkingChanges(changes) => {
render_working_changes(&mut out, changes, ids, opts);
}
Section::Branch { names, commits } => {
let dot_color =
opts.theme.branch_dots[branch_color_idx % opts.theme.branch_dots.len()];
branch_color_idx += 1;
let prev_stacked = idx > 0 && is_stacked_with_next(sections, idx - 1);
let next_stacked = is_stacked_with_next(sections, idx);
render_branch(
&mut out,
names,
commits,
dot_color,
prev_stacked,
next_stacked,
idx < last_idx,
ids,
&opts.theme,
&opts.cwd_prefix,
);
}
Section::Loose(commits) => {
render_loose(
&mut out,
commits,
idx < last_idx,
ids,
&opts.theme,
&opts.cwd_prefix,
);
}
Section::Upstream(info) => {
render_upstream(&mut out, info, &opts.theme);
}
Section::Context(commits) => {
render_context(&mut out, commits, &opts.theme);
}
}
}
out
}
fn render_working_changes(
out: &mut String,
changes: &[FileChange],
ids: &IdAllocator,
opts: &RenderOpts,
) {
let theme = &opts.theme;
writeln!(
out,
"{} {} {}{}{}",
"╭─".color(theme.graph),
ids.get_unstaged().color(theme.shortid).underline(),
"[".color(theme.dim),
"local changes".color(theme.label),
"]".color(theme.dim)
)
.unwrap();
let conflicted: Vec<&FileChange> = changes
.iter()
.filter(|f| f.index == '!' && f.worktree == '!')
.collect();
let tracked: Vec<&FileChange> = changes
.iter()
.filter(|f| f.index != '!' && !(f.index == '?' && f.worktree == '?'))
.collect();
let untracked: Vec<&FileChange> = changes
.iter()
.filter(|f| f.index == '?' && f.worktree == '?')
.collect();
if conflicted.is_empty() && tracked.is_empty() && untracked.is_empty() {
writeln!(
out,
"{} {}",
"│".color(theme.graph),
"no changes".color(theme.dim)
)
.unwrap();
} else {
for change in &conflicted {
writeln!(
out,
"{} {} {} {}",
"│".color(theme.graph),
ids.get_file(&change.path).color(theme.shortid).underline(),
"!!".color(theme.conflict).bold(),
display_path(&change.path, &opts.cwd_prefix)
.color(theme.conflict)
.bold()
)
.unwrap();
}
for change in &tracked {
writeln!(
out,
"{} {} {}{} {}",
"│".color(theme.graph),
ids.get_file(&change.path).color(theme.shortid).underline(),
change.index.to_string().color(theme.staged),
change.worktree.to_string().color(theme.unstaged),
display_path(&change.path, &opts.cwd_prefix)
)
.unwrap();
}
if !untracked.is_empty() {
render_untracked(out, &untracked, ids, opts);
}
}
writeln!(out, "{}", "│".color(theme.graph)).unwrap();
}
fn render_untracked(
out: &mut String,
untracked: &[&FileChange],
ids: &IdAllocator,
opts: &RenderOpts,
) {
if untracked.len() > UNTRACKED_MULTICOLUMN_THRESHOLD
&& let Some(width) = opts.terminal_width
{
render_untracked_multicolumn(out, untracked, ids, width, &opts.theme, &opts.cwd_prefix);
return;
}
render_untracked_single_column(out, untracked, ids, &opts.theme, &opts.cwd_prefix);
}
fn render_untracked_single_column(
out: &mut String,
untracked: &[&FileChange],
ids: &IdAllocator,
theme: &Theme,
cwd_prefix: &str,
) {
for change in untracked {
writeln!(
out,
"{} {} {} {}",
"│".color(theme.graph),
ids.get_file(&change.path).color(theme.shortid).underline(),
" ⁕".color(theme.untracked),
display_path(&change.path, cwd_prefix)
)
.unwrap();
}
}
fn render_untracked_multicolumn(
out: &mut String,
untracked: &[&FileChange],
ids: &IdAllocator,
term_width: u16,
theme: &Theme,
cwd_prefix: &str,
) {
let available = (term_width as usize).saturating_sub(GRAPH_PREFIX_WIDTH);
let separator = " │ "; let separator_width: usize = 5;
let entry_widths: Vec<usize> = untracked
.iter()
.map(|f| {
let sid = ids.get_file(&f.path);
let disp = display_path(&f.path, cwd_prefix);
sid.len() + 1 + 2 + 1 + disp.len()
})
.collect();
let max_entry_width = entry_widths.iter().copied().max().unwrap_or(1);
let col_slot = max_entry_width + separator_width;
let num_cols = (available / col_slot).max(1).min(untracked.len());
let num_rows = untracked.len().div_ceil(num_cols);
for row in 0..num_rows {
write!(out, "{} ", "│".color(theme.graph)).unwrap();
for col in 0..num_cols {
let idx = col * num_rows + row;
if idx >= untracked.len() {
break;
}
let f = untracked[idx];
let sid = ids.get_file(&f.path);
let disp = display_path(&f.path, cwd_prefix);
write!(
out,
"{} {} {}",
sid.color(theme.shortid).underline(),
" ⁕".color(theme.untracked),
disp
)
.unwrap();
let next_idx = (col + 1) * num_rows + row;
if col + 1 < num_cols && next_idx < untracked.len() {
let padding = max_entry_width.saturating_sub(entry_widths[idx]);
write!(out, "{}{}", " ".repeat(padding), separator.color(theme.dim)).unwrap();
}
}
writeln!(out).unwrap();
}
}
#[allow(clippy::too_many_arguments)]
fn render_branch(
out: &mut String,
names: &[(String, Option<RemoteStatus>)],
commits: &[CommitInfo],
dot_color: Color,
prev_stacked: bool,
next_stacked: bool,
more_sections: bool,
ids: &IdAllocator,
theme: &Theme,
cwd_prefix: &str,
) {
for (i, (name, remote)) in names.iter().enumerate() {
let branch_id = ids.get_branch(name);
let connector = if i == 0 && !prev_stacked {
"│╭─"
} else {
"│├─"
};
let remote_indicator = match remote {
Some(RemoteStatus::Synced) => format!(" {}", "✓".color(theme.remote_synced)),
Some(RemoteStatus::Ahead) => format!(" {}", "↑".color(theme.remote_ahead)),
Some(RemoteStatus::Gone) => format!(" {}", "✗".color(theme.remote_gone)),
None => String::new(),
};
writeln!(
out,
"{} {} {}{}{}{}",
connector.color(theme.graph),
branch_id.color(theme.shortid).underline(),
"[".color(theme.dim),
name.color(theme.branch).bold(),
"]".color(theme.dim),
remote_indicator,
)
.unwrap();
}
for commit in commits {
let sid = ids.get_commit(commit.oid);
let rest: String = commit.short_id.chars().skip(sid.len()).collect();
writeln!(
out,
"{}{} {}{} {}",
"│".color(theme.graph),
"●".color(dot_color),
sid.color(theme.shortid).underline(),
rest.color(theme.dim),
commit.message
)
.unwrap();
for (i, file) in commit.files.iter().enumerate() {
let file_sid = format!("{}:{}", sid, i);
writeln!(
out,
"{}{} {} {}{} {}",
"│".color(theme.graph),
"┊".color(dot_color),
file_sid.color(theme.shortid).underline(),
file.index.to_string().color(theme.staged),
file.worktree.to_string().color(theme.unstaged),
display_path(&file.path, cwd_prefix)
)
.unwrap();
}
}
if next_stacked {
writeln!(out, "{}", "││".color(theme.graph)).unwrap();
} else {
writeln!(out, "{}", "├╯".color(theme.graph)).unwrap();
if more_sections {
writeln!(out, "{}", "│".color(theme.graph)).unwrap();
}
}
}
fn render_loose(
out: &mut String,
commits: &[CommitInfo],
more_sections: bool,
ids: &IdAllocator,
theme: &Theme,
cwd_prefix: &str,
) {
for commit in commits {
let sid = ids.get_commit(commit.oid);
let rest: String = commit.short_id.chars().skip(sid.len()).collect();
writeln!(
out,
"{} {}{} {}",
"●".color(theme.graph),
sid.color(theme.shortid).underline(),
rest.color(theme.dim),
commit.message
)
.unwrap();
for (i, file) in commit.files.iter().enumerate() {
let file_sid = format!("{}:{}", sid, i);
writeln!(
out,
"{} {} {}{} {}",
"┊".color(theme.graph),
file_sid.color(theme.shortid).underline(),
file.index.to_string().color(theme.staged),
file.worktree.to_string().color(theme.unstaged),
display_path(&file.path, cwd_prefix)
)
.unwrap();
}
}
if more_sections {
writeln!(out, "{}", "│".color(theme.graph)).unwrap();
}
}
fn render_upstream(out: &mut String, info: &UpstreamInfo, theme: &Theme) {
if info.commits_ahead > 0 {
let count_text = format!(
"\u{23EB} {} new commit{}",
info.commits_ahead,
if info.commits_ahead == 1 { "" } else { "s" }
)
.color(theme.message);
writeln!(
out,
"{}{} {}{}{} {}",
"│".color(theme.graph),
"●".color(theme.graph),
"[".color(theme.dim),
info.label.color(theme.branch).bold(),
"]".color(theme.dim),
count_text
)
.unwrap();
writeln!(
out,
"{} {} {} {} {}",
"├╯".color(theme.graph),
info.base_short_id.color(theme.dim),
"(common base)".color(theme.label),
info.base_date.color(theme.dim),
info.base_message.color(theme.message)
)
.unwrap();
} else {
writeln!(
out,
"{} {} {} {}{}{} {}",
"●".color(theme.graph),
info.base_short_id.color(theme.dim),
"(upstream)".color(theme.label),
"[".color(theme.dim),
info.label.color(theme.branch).bold(),
"]".color(theme.dim),
info.base_message.color(theme.message)
)
.unwrap();
}
}
fn render_context(out: &mut String, commits: &[ContextCommit], theme: &Theme) {
for commit in commits {
writeln!(
out,
"{} {} {} {}",
"·".color(theme.dim),
commit.short_hash.color(theme.dim),
commit.date.color(theme.dim),
commit.message.color(theme.message),
)
.unwrap();
}
}
#[cfg(test)]
#[path = "graph_test.rs"]
mod tests;