use crate::Res;
use crate::config::{Config, FileHeader};
use crate::error::Error;
use crate::git::diff::{Diff, DiffType};
use crate::gitu_diff;
use crate::item_data::ItemData;
use crate::item_data::Ref;
use crate::rebase_todo::{RebaseTodo, TodoAction, TodoEntry};
use crate::style::{Color, Style};
use git2::Oid;
use git2::Repository;
use regex::Regex;
use std::cell::RefCell;
use std::cmp::Ordering;
use std::collections::{HashMap, HashSet};
use std::hash::DefaultHasher;
use std::hash::Hash;
use std::hash::Hasher;
use std::iter;
use std::ops::Range;
use std::process::Command;
use std::rc::Rc;
use std::time::SystemTime;
pub type ItemId = u64;
pub(crate) type RenderedRow = Vec<(String, Style)>;
#[derive(Default, Clone)]
pub(crate) struct RenderParams {
pub size: (u16, u16),
pub features: Rc<[String]>,
pub context: Option<Rc<str>>,
pub streaming: bool,
}
impl RenderParams {
pub(crate) fn width(&self) -> usize {
(self.size.0 as usize).saturating_sub(2)
}
}
#[derive(Default, Clone, Debug)]
pub(crate) struct Item {
pub(crate) id: ItemId,
pub(crate) default_collapsed: bool,
pub(crate) depth: usize,
pub(crate) unselectable: bool,
pub(crate) data: ItemData,
pub(crate) rendered: Option<Rc<RenderedRow>>,
pub(crate) folded: Option<Rc<RenderedRow>>,
pub(crate) header: Option<Rc<RenderedRow>>,
pub(crate) decorates_next: bool,
pub(crate) decorates_previous: bool,
pub(crate) folds_like: Option<ItemId>,
}
pub(crate) fn create_diff_items(
config: &Config,
params: &RenderParams,
diff: &Rc<Diff>,
depth: usize,
default_collapsed: bool,
commit: Option<String>,
) -> Vec<Item> {
let rendered = (!config.general.diff_renderer.command.is_empty() && !diff.text.is_empty())
.then(|| {
crate::diff_renderer::run(
&config.general.diff_renderer.command,
Some(&diff.text),
params,
None,
)
})
.flatten();
let items = drawn_diff_items(
config,
diff,
rendered.as_deref(),
false,
depth,
default_collapsed,
commit,
);
headed_as_configured(config, params, items)
}
pub(crate) fn headed_as_configured(
config: &Config,
params: &RenderParams,
items: Vec<Item>,
) -> Vec<Item> {
match config.general.file_header {
FileHeader::Delta => items,
FileHeader::Stat => headed_by_counted_stat(items, params.width(), false),
}
}
pub(crate) fn drawn_diff_items(
config: &Config,
diff: &Rc<Diff>,
rendered: Option<&str>,
cut_unreached: bool,
depth: usize,
default_collapsed: bool,
commit: Option<String>,
) -> Vec<Item> {
let parsed = crate::diff_renderer::parse_ansi_lines(rendered.unwrap_or_default());
let items = rendered_diff_items(
diff,
&parsed.lines,
depth,
default_collapsed,
commit,
config.general.visit_context_lines,
);
if cut_unreached {
reached_files(items)
} else {
items
}
}
pub(crate) fn headed_by_counted_stat(
mut items: Vec<Item>,
width: usize,
summarised: bool,
) -> Vec<Item> {
let stats: Vec<(usize, FileStat)> = items
.iter()
.enumerate()
.filter_map(|(item_i, item)| match &item.data {
ItemData::Delta { diff, file_i, .. } => Some((item_i, FileStat::of(diff, *file_i))),
_ => None,
})
.collect();
let Some(layout) = StatLayout::of(stats.iter().map(|(_, stat)| stat), width) else {
return items;
};
for (item_i, stat) in &stats {
let row = Some(Rc::new(layout.row(stat)));
items[*item_i].header = row.clone();
items[*item_i].folded = row;
}
if summarised {
let summary = stat_summary(stats.iter().map(|(_, stat)| stat));
let depth = items[stats[0].0].depth;
items.push(Item {
depth,
unselectable: true,
rendered: Some(Rc::new(vec![(summary, Style::new())])),
..Default::default()
});
}
items
}
struct FileStat {
name: String,
added: usize,
removed: usize,
}
impl FileStat {
fn of(diff: &Diff, file_i: usize) -> Self {
let header = &diff.file_diffs[file_i].header;
let old = header.old_file.fmt(&diff.text);
let new = header.new_file.fmt(&diff.text);
let name = match header.status {
gitu_diff::Status::Renamed | gitu_diff::Status::Copied => format!("{old} => {new}"),
gitu_diff::Status::Deleted => old.into_owned(),
_ => new.into_owned(),
};
let lines = || {
(0..diff.file_diffs[file_i].hunks.len())
.flat_map(move |hunk_i| diff.hunk_content(file_i, hunk_i).lines())
};
Self {
name,
added: lines().filter(|line| line.starts_with('+')).count(),
removed: lines().filter(|line| line.starts_with('-')).count(),
}
}
fn changed(&self) -> usize {
self.added + self.removed
}
}
struct StatLayout {
name_width: usize,
count_width: usize,
bar_width: usize,
largest: usize,
}
impl StatLayout {
fn of<'a>(stats: impl Iterator<Item = &'a FileStat> + Clone, width: usize) -> Option<Self> {
let largest = stats.clone().map(FileStat::changed).max()?;
let longest = stats.map(|stat| stat.name.chars().count()).max()?;
let count_width = largest.to_string().len();
let (width, fixed) = (width as isize, count_width as isize + 6);
let (mut name_width, mut bar_width) = (longest as isize, largest as isize);
if name_width + fixed + bar_width > width {
bar_width = bar_width.min((width * 3 / 8 - fixed).max(6));
if name_width > width - fixed - bar_width {
name_width = width - fixed - bar_width;
} else {
bar_width = width - fixed - name_width;
}
}
Some(Self {
name_width: name_width.max(0) as usize,
count_width,
bar_width: bar_width.max(0) as usize,
largest,
})
}
fn row(&self, stat: &FileStat) -> RenderedRow {
let (added, removed) = self.marks(stat);
let changed = stat.changed();
let mark = |symbol: &str, count: usize, color: Color| {
let mut style = Style::new();
style.fg = Some(color);
(symbol.repeat(count), style)
};
vec![
(
format!(
" {:<name_width$} | {changed:>count_width$}{}",
shortened(&stat.name, self.name_width),
if changed > 0 { " " } else { "" },
name_width = self.name_width,
count_width = self.count_width,
),
Style::new(),
),
mark("+", added, Color::Green),
mark("-", removed, Color::Red),
]
}
fn marks(&self, stat: &FileStat) -> (usize, usize) {
let (added, removed) = (stat.added, stat.removed);
if self.bar_width > self.largest {
return (added, removed);
}
let scaled = |count: usize| {
if count == 0 {
0
} else {
1 + count * (self.bar_width.saturating_sub(1)) / self.largest
}
};
let mut total = scaled(added + removed);
if total < 2 && added > 0 && removed > 0 {
total = 2;
}
if added < removed {
let added = scaled(added);
(added, total - added)
} else {
let removed = scaled(removed);
(total - removed, removed)
}
}
}
fn shortened(name: &str, width: usize) -> String {
let length = name.chars().count();
if length <= width {
return name.to_owned();
}
let tail: String = name.chars().skip(length - width + 3).collect();
match tail.find('/') {
Some(slash) => format!("...{}", &tail[slash..]),
None => format!("...{tail}"),
}
}
fn stat_summary<'a>(stats: impl Iterator<Item = &'a FileStat>) -> String {
let (files, added, removed) = stats.fold((0, 0, 0), |(files, added, removed), stat| {
(files + 1, added + stat.added, removed + stat.removed)
});
let plural = |count: usize| if count == 1 { "" } else { "s" };
let mut summary = format!(" {files} file{} changed", plural(files));
if added > 0 || removed == 0 {
summary += &format!(", {added} insertion{}(+)", plural(added));
}
if removed > 0 || added == 0 {
summary += &format!(", {removed} deletion{}(-)", plural(removed));
}
summary
}
fn reached_files(mut items: Vec<Item>) -> Vec<Item> {
let reached: HashSet<usize> = items
.iter()
.filter_map(|item| match &item.data {
ItemData::HunkLine { file_i, .. } => Some(*file_i),
ItemData::Delta { file_i, .. } if item.rendered.is_some() => Some(*file_i),
_ => None,
})
.collect();
if let Some(unreached) = items.iter().position(
|item| matches!(&item.data, ItemData::Delta { file_i, .. } if !reached.contains(file_i)),
) {
items.truncate(unreached);
}
items
}
fn rendered_diff_items(
diff: &Rc<Diff>,
lines: &[crate::diff_renderer::ParsedLine],
depth: usize,
default_collapsed: bool,
commit: Option<String>,
visit_context_lines: bool,
) -> Vec<Item> {
use crate::diff_renderer::LineKind;
let index = crate::diff_renderer::DiffIndex::of(diff);
let mut headers_by_file: HashMap<usize, Vec<RenderedRow>> = HashMap::new();
let mut headers_by_hunk: HashMap<(usize, usize), Vec<RenderedRow>> = HashMap::new();
let mut file_header: Option<(usize, &str)> = None;
let mut next_file = 0;
let mut content_by_hunk: HashMap<(usize, usize), Vec<Item>> = HashMap::new();
let mut hunk_lines = HashMap::new();
let mut last_content: Option<(usize, usize, usize)> = None;
for line in lines {
let previous_file_header = file_header.take();
let Some(first) = line.records.first() else {
last_content = None;
continue; };
match first.kind {
LineKind::Commit => last_content = None,
LineKind::FileHeader => {
last_content = None;
let file_i = match previous_file_header {
Some((file_i, path)) if path == first.file => Some(file_i),
_ => crate::diff_renderer::resolve_file(diff, first, next_file),
};
let Some(file_i) = file_i else {
continue;
};
next_file = file_i + 1;
file_header = Some((file_i, &first.file));
if !line.text.trim().is_empty() {
headers_by_file
.entry(file_i)
.or_default()
.push(rendered_spans(line));
}
}
LineKind::HunkHeader => {
last_content = None;
if line.text.trim().is_empty() {
continue;
}
if let Some(key) = index.hunk(first) {
headers_by_hunk
.entry(key)
.or_default()
.push(rendered_spans(line));
}
}
LineKind::Context | LineKind::Added | LineKind::Deleted => {
let Some((file_i, hunk_i, line_i)) = index.line(first) else {
continue;
};
let line_indices = line
.records
.iter()
.filter_map(|meta| index.line(meta))
.filter(|(f, h, _)| (*f, *h) == (file_i, hunk_i))
.map(|(_, _, li)| li)
.collect();
let (hunk_hash, line_ranges) =
hunk_lines.entry((file_i, hunk_i)).or_insert_with(|| {
let lines = line_range_iterator(diff.hunk_content(file_i, hunk_i))
.map(|(range, _)| range)
.collect::<Vec<_>>();
(
hash([diff.file_diff_header(file_i), diff.hunk(file_i, hunk_i)]),
lines,
)
});
let hunk_hash = *hunk_hash;
let line_range = line_ranges.get(line_i).cloned().unwrap_or_default();
let rows = content_by_hunk.entry((file_i, hunk_i)).or_default();
let continues_previous = last_content
.replace((file_i, hunk_i, line_i))
.is_some_and(|previous| previous == (file_i, hunk_i, line_i));
if continues_previous {
rows.push(Item {
id: hunk_hash,
depth: depth + 3,
unselectable: true,
rendered: Some(Rc::new(rendered_spans(line))),
..Default::default()
});
continue;
}
rows.push(Item {
id: hunk_hash,
depth: depth + 2,
unselectable: !visit_context_lines && matches!(first.kind, LineKind::Context),
data: ItemData::HunkLine {
diff: Rc::clone(diff),
file_i,
hunk_i,
line_i,
line_range,
line_indices,
},
rendered: Some(Rc::new(rendered_spans(line))),
..Default::default()
});
}
}
}
let mut items = Vec::new();
for (file_i, file_diff) in diff.file_diffs.iter().enumerate() {
let file_hash = file_id(diff, file_i);
let (above, anchor, below) = split_at_name(headers_by_file.remove(&file_i));
for row in above {
items.push(Item {
id: file_hash,
depth,
unselectable: true,
decorates_next: true,
rendered: Some(Rc::new(row)),
..Default::default()
});
}
items.push(Item {
id: file_hash,
default_collapsed,
depth,
data: ItemData::Delta {
diff: Rc::clone(diff),
file_i,
commit: commit.clone(),
},
rendered: anchor.map(Rc::new),
..Default::default()
});
for row in below {
items.push(Item {
id: file_hash,
depth: depth + 1,
unselectable: true,
decorates_previous: true,
rendered: Some(Rc::new(row)),
..Default::default()
});
}
for hunk_i in 0..file_diff.hunks.len() {
let hunk_hash = hash([diff.file_diff_header(file_i), diff.hunk(file_i, hunk_i)]);
let hunk = ItemData::Hunk {
diff: Rc::clone(diff),
file_i,
hunk_i,
};
let mut header_rows = headers_by_hunk
.remove(&(file_i, hunk_i))
.unwrap_or_default()
.into_iter();
items.push(Item {
id: hunk_hash,
depth: depth + 1,
data: hunk,
rendered: header_rows.next().map(Rc::new),
..Default::default()
});
for extra in header_rows {
items.push(Item {
id: hunk_hash,
depth: depth + 2,
unselectable: true,
rendered: Some(Rc::new(extra)),
..Default::default()
});
}
if let Some(rows) = content_by_hunk.remove(&(file_i, hunk_i)) {
items.extend(rows);
}
}
}
items
}
fn line_range_iterator(content: &str) -> impl Iterator<Item = (Range<usize>, &str)> {
content
.split_inclusive('\n')
.scan(0usize, |prev_line_end, current_line| {
let line_start = *prev_line_end;
let actual_line_length = current_line.len();
let visual_line_length = if current_line.ends_with("\r\n") {
actual_line_length - 2
} else {
actual_line_length - 1
};
let actual_line_end = line_start + actual_line_length;
let visual_line_end = line_start + visual_line_length;
*prev_line_end = actual_line_end;
Some((line_start..visual_line_end, current_line))
})
}
fn split_at_name(
rows: Option<Vec<RenderedRow>>,
) -> (Vec<RenderedRow>, Option<RenderedRow>, Vec<RenderedRow>) {
let mut rows = rows.unwrap_or_default();
let names = |row: &RenderedRow| {
row.iter()
.any(|(text, _)| text.chars().any(char::is_alphanumeric))
};
let Some(at) = rows.iter().position(names) else {
let mut rows = rows.into_iter();
return (Vec::new(), rows.next(), rows.collect());
};
let below = rows.split_off(at + 1);
let anchor = rows.pop();
(rows, anchor, below)
}
pub(crate) fn plain_rows(text: &str) -> Vec<Item> {
rows_of(crate::diff_renderer::parse_ansi_lines(text).lines.iter())
}
fn rows_of<'a>(lines: impl Iterator<Item = &'a crate::diff_renderer::ParsedLine>) -> Vec<Item> {
lines
.map(|line| Item {
depth: 0,
rendered: Some(Rc::new(rendered_spans(line))),
..Default::default()
})
.collect()
}
fn rendered_spans(line: &crate::diff_renderer::ParsedLine) -> RenderedRow {
line.runs
.iter()
.map(|(range, style)| {
let text = line.text[range.clone()].replace('\t', " ");
let text = if let Some(link) = line
.hyperlinks
.iter()
.find(|link| link.range.contains(&range.start))
{
crate::ui::osc8_hyperlink(&text, &link.uri)
} else {
text
};
(text, *style)
})
.collect()
}
pub(crate) fn stash_list(repo: &Repository, limit: usize) -> Res<Vec<Item>> {
Ok(repo
.reflog("refs/stash")
.map_err(Error::StashList)?
.iter()
.enumerate()
.map(|(i, stash)| -> Res<Item> {
let stash_id = stash.id_new();
let stash_ref = format!("stash@{{{}}}", i);
Ok(Item {
id: hash(stash_id),
depth: 1,
data: ItemData::Stash {
message: stash.message().unwrap_or("").to_string(),
stash_ref,
id: i,
},
..Default::default()
})
})
.map(|result| match result {
Ok(item) => item,
Err(err) => {
let err = err.to_string();
Item {
id: hash(&err),
data: ItemData::Error(err),
..Default::default()
}
}
})
.take(limit)
.collect::<Vec<_>>())
}
fn short_age(time: git2::Time) -> String {
const MINUTE: i64 = 60;
const HOUR: i64 = 60 * MINUTE;
const DAY: i64 = 24 * HOUR;
const WEEK: i64 = 7 * DAY;
const MONTH: i64 = 30 * DAY;
const YEAR: i64 = 365 * DAY;
let now = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map_or(0, |since_epoch| since_epoch.as_secs() as i64);
let age = (now - time.seconds()).max(0);
if age < HOUR {
format!("{}m", age / MINUTE)
} else if age < DAY {
format!("{}h", age / HOUR)
} else if age < WEEK {
format!("{}d", age / DAY)
} else if age < MONTH {
format!("{}w", age / WEEK)
} else if age < YEAR {
format!("{}M", age / MONTH)
} else {
format!("{}y", age / YEAR)
}
}
pub(crate) fn log(
repo: &Repository,
limit: usize,
rev: Option<Oid>,
msg_regex: Option<Regex>,
) -> Res<Vec<Item>> {
let mut revwalk = repo.revwalk().map_err(Error::ReadLog)?;
if let Some(r) = rev {
revwalk.push(r).map_err(Error::ReadLog)?;
} else if revwalk.push_head().is_err() {
return Ok(vec![]);
}
let references = commit_refs(repo)?;
let items: Vec<Item> = revwalk
.map(|oid_result| -> Res<Option<Item>> {
let oid = oid_result.map_err(Error::ReadLog)?;
let commit = repo.find_commit(oid).map_err(Error::ReadLog)?;
let short_id = commit.as_object().short_id().map_err(Error::ReadOid)?;
let short_id = String::from_utf8_lossy(&short_id).to_string();
if let Some(re) = &msg_regex
&& !re.is_match(commit.message().unwrap_or(""))
{
return Ok(None);
}
let associated_references: Vec<_> = references
.iter()
.filter(|(commit, _)| commit.id() == oid)
.map(|(_, reference)| reference.clone())
.collect();
let data = ItemData::Commit {
oid: oid.to_string(),
short_id,
associated_references,
summary: commit.summary().unwrap_or("").to_string(),
author: commit.author().name().unwrap_or("").to_string(),
age: short_age(commit.author().when()),
};
Ok(Some(Item {
id: hash(oid),
depth: 1,
data,
..Default::default()
}))
})
.filter_map(|result| match result {
Ok(item) => item,
Err(err) => {
let err = err.to_string();
Some(Item {
id: hash(&err),
data: ItemData::Error(err),
..Default::default()
})
}
})
.take(limit)
.collect();
if items.is_empty() {
Ok(vec![Item {
data: ItemData::Raw("No commits found".to_string()),
..Default::default()
}])
} else {
Ok(items)
}
}
fn commit_refs(repo: &Repository) -> Res<Vec<(git2::Commit<'_>, Ref)>> {
Ok(repo
.references()
.map_err(Error::ReadLog)?
.filter_map(Result::ok)
.filter_map(
|reference| match (reference.peel_to_commit(), reference.shorthand()) {
(Ok(target), Some(name)) => {
if name.ends_with("/HEAD") || name.starts_with("prefetch/remotes/") {
return None;
}
let name = name.to_owned();
let ref_kind = if reference.is_remote() {
Ref::Remote(name)
} else if reference.is_tag() {
Ref::Tag(name)
} else {
Ref::Head(name)
};
Some((target, ref_kind))
}
_ => None,
},
)
.collect())
}
pub(crate) fn rendered_log_items(repo: &Repository, rendered: &str) -> Option<Vec<Item>> {
Some(log_items(repo, &commits_in(rendered)?))
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn log_of_patches_items(
config: &Config,
params: &RenderParams,
repo: &Repository,
patches: Vec<String>,
rendered: &str,
drawn_whole: bool,
diff_type: &DiffType,
stat: bool,
default_collapsed: bool,
) -> Option<Vec<Item>> {
use crate::diff_renderer::LineKind;
let parsed = crate::diff_renderer::parse_ansi_lines(rendered);
parsed.protocol_version?;
let blocks = crate::diff_renderer::commit_blocks(&parsed.lines);
let is_diff_row = |line: &crate::diff_renderer::ParsedLine| {
line.text.starts_with("diff --git ")
|| line
.records
.iter()
.any(|record| record.kind != LineKind::Commit)
};
let with_diffs = blocks
.iter()
.filter(|block| block.rows.iter().any(is_diff_row))
.count();
if blocks.iter().all(|block| block.commit.is_none())
|| with_diffs == 0
|| with_diffs > patches.len()
|| (drawn_whole && with_diffs < patches.len())
{
return None;
}
let references = commit_refs(repo).unwrap_or_default();
let mut patches = patches.into_iter();
let mut items = Vec::new();
for block in &blocks {
let header_end = block
.rows
.iter()
.position(is_diff_row)
.unwrap_or(block.rows.len());
let (header, diff_rows) = block.rows.split_at(header_end);
let commit = RenderedCommit {
oid: block.commit.map(String::from),
rows: header
.iter()
.map(|row| Rc::new(rendered_spans(row)))
.collect(),
};
items.extend(commit_block_items(repo, &references, &commit));
if diff_rows.is_empty() {
continue;
}
let patch = patches.next()?;
let diff = Rc::new(Diff {
file_diffs: gitu_diff::Parser::new(&patch).parse_diff().ok()?,
diff_type: diff_type.clone(),
commit: commit.oid.clone(),
text: patch,
});
let diff_items = rendered_diff_items(
&diff,
diff_rows,
2,
default_collapsed,
commit.oid,
config.general.visit_context_lines,
);
let diff_items = if stat {
headed_by_counted_stat(diff_items, params.width(), true)
} else {
headed_as_configured(config, params, diff_items)
};
items.extend(if drawn_whole {
diff_items
} else {
reached_files(diff_items)
});
}
Some(items)
}
pub(crate) fn patches_of(text: &str, file_diffs: &[gitu_diff::FileDiff]) -> Vec<String> {
let mut runs: Vec<std::ops::Range<usize>> = Vec::new();
for file_diff in file_diffs {
match runs.last_mut() {
Some(run) if run.end == file_diff.range.start => run.end = file_diff.range.end,
_ => runs.push(file_diff.range.clone()),
}
}
runs.into_iter().map(|run| text[run].to_owned()).collect()
}
fn log_items(repo: &Repository, blocks: &[RenderedCommit]) -> Vec<Item> {
let references = commit_refs(repo).unwrap_or_default();
blocks
.iter()
.flat_map(|block| commit_block_items(repo, &references, block))
.collect()
}
pub(crate) struct RenderedCommit {
oid: Option<String>,
rows: Vec<Rc<RenderedRow>>,
}
fn rendered_commits(
config: &Config,
repo: &Repository,
params: &RenderParams,
revs: &[String],
) -> Option<Vec<RenderedCommit>> {
let dir = repo.workdir().unwrap_or_else(|| repo.path());
let log = Command::new("git")
.current_dir(dir)
.args(["-c", "color.ui=always", "log", "--no-walk=unsorted"])
.args(&config.general.log_args)
.args(revs)
.output()
.ok()
.filter(|output| output.status.success())?;
let output = crate::diff_renderer::run(
&config.general.diff_renderer.command,
Some(&String::from_utf8_lossy(&log.stdout)),
params,
Some(dir),
)?;
commits_in(&output)
}
fn commits_in(rendered: &str) -> Option<Vec<RenderedCommit>> {
let parsed = crate::diff_renderer::parse_ansi_lines(rendered);
let blocks = crate::diff_renderer::commit_blocks(&parsed.lines);
if blocks.iter().all(|block| block.commit.is_none()) {
return None;
}
Some(
blocks
.iter()
.map(|block| RenderedCommit {
oid: block.commit.map(String::from),
rows: block
.rows
.iter()
.map(|row| Rc::new(rendered_spans(row)))
.collect(),
})
.collect(),
)
}
pub(crate) fn rendered_commit_rows(
config: &Config,
repo: &Repository,
params: &RenderParams,
revs: &[String],
) -> HashMap<String, Vec<Rc<RenderedRow>>> {
if config.general.diff_renderer.command.is_empty() || revs.is_empty() {
return HashMap::new();
}
rendered_commits(config, repo, params, revs)
.unwrap_or_default()
.into_iter()
.filter_map(|block| {
let commit = repo
.revparse_single(&block.oid?)
.ok()?
.peel_to_commit()
.ok()?;
Some((commit.id().to_string(), block.rows))
})
.collect()
}
fn commit_block_items(
repo: &Repository,
references: &[(git2::Commit, Ref)],
block: &RenderedCommit,
) -> Vec<Item> {
let commit = block
.oid
.as_ref()
.and_then(|oid| repo.revparse_single(oid).ok())
.and_then(|object| object.peel_to_commit().ok());
let Some(commit) = commit else {
return block.rows.iter().map(|row| row_item(0, 1, row)).collect();
};
block_items(hash(commit.id()), &block.rows, |row| Item {
data: commit_data(&commit, references),
unselectable: false,
..row_item(hash(commit.id()), 1, row)
})
}
fn block_items(
id: ItemId,
rows: &[Rc<RenderedRow>],
anchor: impl Fn(&Rc<RenderedRow>) -> Item,
) -> Vec<Item> {
let Some(anchor_i) = rows.iter().position(|row| !is_decoration(row)) else {
return rows.iter().map(|row| row_item(id, 1, row)).collect();
};
rows.iter()
.enumerate()
.map(|(i, row)| match i.cmp(&anchor_i) {
Ordering::Less => row_item(id, 1, row),
Ordering::Greater => row_item(id, 2, row),
Ordering::Equal => anchor(row),
})
.collect()
}
fn is_decoration(row: &RenderedRow) -> bool {
row.iter()
.flat_map(|(text, _)| text.chars())
.all(|c| c.is_whitespace() || BOX_DRAWING.contains(&c))
}
const BOX_DRAWING: std::ops::RangeInclusive<char> = '\u{2500}'..='\u{257f}';
fn row_item(id: ItemId, depth: usize, row: &Rc<RenderedRow>) -> Item {
Item {
id,
depth,
unselectable: true,
rendered: Some(Rc::clone(row)),
..Default::default()
}
}
pub(crate) fn rebase_todo_items(
config: &Config,
repo: &Repository,
todo: &Rc<RefCell<RebaseTodo>>,
rows_by_commit: &HashMap<String, Vec<Rc<RenderedRow>>>,
) -> Vec<Item> {
let mut items = Vec::new();
for (index, entry) in todo.borrow().entries.iter().enumerate() {
let data = ItemData::RebaseTodo {
todo: Rc::clone(todo),
index,
};
let (id, keyword, rows) = match entry {
TodoEntry::Commit { action, oid } => (
hash(oid),
Some(*action),
rows_by_commit
.get(oid)
.cloned()
.unwrap_or_else(|| vec![Rc::new(commit_row(config, repo, oid))]),
),
TodoEntry::Other(instruction) => (
hash(instruction),
None,
vec![Rc::new(vec![(instruction.clone(), Style::new())])],
),
};
items.extend(block_items(id, &rows, |row| Item {
data: data.clone(),
unselectable: false,
..row_item(id, 1, &Rc::new(action_prefixed(config, keyword, row)))
}));
}
items
}
fn action_prefixed(
config: &Config,
action: Option<TodoAction>,
row: &Rc<RenderedRow>,
) -> RenderedRow {
let Some(action) = action else {
return row.as_ref().clone();
};
let styles = &config.style.rebase_todo;
let style = Style::from(match action {
TodoAction::Pick => return blank_prefixed(row),
TodoAction::Reword => &styles.reword,
TodoAction::Edit => &styles.edit,
TodoAction::Squash => &styles.squash,
TodoAction::Fixup => &styles.fixup,
TodoAction::Drop => &styles.drop,
});
iter::once((format!("{:<7}", action.keyword()), style))
.chain(row.iter().cloned())
.collect()
}
fn blank_prefixed(row: &Rc<RenderedRow>) -> RenderedRow {
iter::once((" ".repeat(7), Style::new()))
.chain(row.iter().cloned())
.collect()
}
fn commit_row(config: &Config, repo: &Repository, oid: &str) -> RenderedRow {
let commit = Oid::from_str(oid)
.ok()
.and_then(|oid| repo.find_commit(oid).ok());
let Some(commit) = commit else {
return vec![(oid.to_string(), Style::new())];
};
let short_id = commit
.as_object()
.short_id()
.map(|id| String::from_utf8_lossy(&id).to_string())
.unwrap_or_default();
vec![
(format!("{short_id} "), Style::from(&config.style.hash)),
(commit.summary().unwrap_or("").to_string(), Style::new()),
]
}
fn commit_data(commit: &git2::Commit, references: &[(git2::Commit, Ref)]) -> ItemData {
let short_id = commit
.as_object()
.short_id()
.map(|id| String::from_utf8_lossy(&id).to_string())
.unwrap_or_default();
ItemData::Commit {
oid: commit.id().to_string(),
short_id,
associated_references: references
.iter()
.filter(|(target, _)| target.id() == commit.id())
.map(|(_, reference)| reference.clone())
.collect(),
summary: commit.summary().unwrap_or("").to_string(),
author: commit.author().name().unwrap_or("").to_string(),
age: short_age(commit.author().when()),
}
}
pub(crate) fn blank_line() -> Item {
Item {
depth: 0,
unselectable: true,
..Default::default()
}
}
fn file_id(diff: &Diff, file_i: usize) -> ItemId {
file_id_as(diff, file_i, &diff.diff_type)
}
pub(crate) fn file_id_as(diff: &Diff, file_i: usize, diff_type: &DiffType) -> ItemId {
let paths = |file_i| diff.file_diff_header(file_i).lines().next().unwrap_or("");
let earlier = (0..file_i).filter(|&i| paths(i) == paths(file_i)).count();
hash((
"file",
format!("{diff_type:?}"),
&diff.commit,
paths(file_i),
earlier,
))
}
pub(crate) fn hash<T: Hash>(x: T) -> ItemId {
let mut hasher = DefaultHasher::new();
x.hash(&mut hasher);
hasher.finish()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::diff_renderer::parse_ansi_lines;
use crate::git::diff::{Diff, DiffType};
const DIFF: &str = "diff --git a/f.rs b/f.rs\n\
index 1..2 100644\n\
--- a/f.rs\n\
+++ b/f.rs\n\
@@ -1,2 +1,2 @@\n\
\x20keep\n\
+added\n";
fn diff_from(text: &str) -> Rc<Diff> {
Rc::new(Diff {
text: text.to_string(),
diff_type: DiffType::WorkdirToIndex,
file_diffs: crate::gitu_diff::Parser::new(text).parse_diff().unwrap(),
commit: None,
})
}
fn hunk_line_rows(items: &[Item]) -> Vec<(usize, bool)> {
items
.iter()
.skip_while(|item| !matches!(item.data, ItemData::HunkLine { .. }))
.map(|item| (item.depth, item.unselectable))
.collect()
}
#[test]
fn rendered_spans_keep_hyperlinks() {
let line = &parse_ansi_lines(
"plain \x1b]8;;file:///tmp/a.rs:12\x1b\\linked\x1b]8;;\x1b\\ plain\n",
)
.lines[0];
assert_eq!(
rendered_spans(line)[1].0,
"\x1b]8;;file:///tmp/a.rs:12\x1b\\linked\x1b]8;;\x1b\\"
);
}
fn file_header_rows(items: &[Item]) -> Vec<(usize, bool, bool, String)> {
items
.iter()
.take_while(|item| !matches!(item.data, ItemData::Hunk { .. }))
.map(|item| {
let text = item
.rendered
.iter()
.flat_map(|row| row.iter())
.map(|(text, _)| text.as_str())
.collect();
(
item.depth,
!item.unselectable,
matches!(item.data, ItemData::Delta { .. }),
text,
)
})
.collect()
}
const ADDED: &str = "\x1b]1717;1;a;2;;f.rs\x1b\\+added\n";
#[test]
fn the_renderers_file_header_heads_the_file() {
let diff = diff_from(DIFF);
let lines = parse_ansi_lines(&format!(
"\x1b]1717;1\x1b\\\n\
\x1b]1717;1;f;;;f.rs\x1b\\• f.rs\n\
\x1b]1717;1;f;;;f.rs\x1b\\──────\n\
{ADDED}"
))
.lines;
let items = rendered_diff_items(&diff, &lines, 0, false, None, false);
assert_eq!(
file_header_rows(&items),
[
(0, true, true, "• f.rs".into()),
(1, false, false, "──────".into()),
]
);
}
#[test]
fn decoration_above_the_file_name_stays_above_it() {
let diff = diff_from(DIFF);
let lines = parse_ansi_lines(&format!(
"\x1b]1717;1\x1b\\\n\
\x1b]1717;1;f;;;f.rs\x1b\\─────┐\n\
\x1b]1717;1;f;;;f.rs\x1b\\• f.rs │\n\
\x1b]1717;1;f;;;f.rs\x1b\\─────┘\n\
{ADDED}"
))
.lines;
let items = rendered_diff_items(&diff, &lines, 0, false, None, false);
assert_eq!(
file_header_rows(&items),
[
(0, false, false, "─────┐".into()),
(0, true, true, "• f.rs │".into()),
(1, false, false, "─────┘".into()),
]
);
}
#[test]
fn without_the_renderers_file_header_gitu_draws_its_own() {
let diff = diff_from(DIFF);
let lines = parse_ansi_lines(&format!("\x1b]1717;1\x1b\\\n{ADDED}")).lines;
let items = rendered_diff_items(&diff, &lines, 0, false, None, false);
assert_eq!(file_header_rows(&items), [(0, true, true, String::new())]);
}
#[test]
fn each_file_of_a_path_seen_twice_has_its_own_header() {
let diff = diff_from(&DIFF.repeat(2));
let lines = parse_ansi_lines(&format!(
"\x1b]1717;1\x1b\\\n\
\x1b]1717;1;f;;;f.rs\x1b\\• first\n\
{ADDED}\
\x1b]1717;1;f;;;f.rs\x1b\\• second\n\
{ADDED}"
))
.lines;
let items = rendered_diff_items(&diff, &lines, 0, false, None, false);
let file_rows: Vec<String> = items
.iter()
.filter(|item| matches!(item.data, ItemData::Delta { .. }))
.map(|item| item.rendered.as_ref().unwrap()[0].0.clone())
.collect();
assert_eq!(file_rows, ["• first", "• second"]);
}
#[test]
fn wrapped_row_continuations_nest_under_their_line() {
let diff = diff_from(DIFF);
let lines = parse_ansi_lines(
"\x1b]1717;1\x1b\\\n\
\x1b]1717;1;a;2;;f.rs\x1b\\+added the first part of a long line\n\
\x1b]1717;1;a;2;;f.rs\x1b\\ and its wrapped remainder\n",
)
.lines;
let items = rendered_diff_items(&diff, &lines, 0, false, None, false);
assert_eq!(
hunk_line_rows(&items),
vec![(2, false), (3, true)],
"the wrapped remainder is not a second selectable diff line"
);
}
#[test]
fn a_render_carrying_no_content_records_has_no_lines() {
let diff = diff_from(DIFF);
let mut config = crate::config::init_test_config().unwrap();
config.general.diff_renderer.command = ["sh", "-c", r"printf '\033]1717;1\033\\\n'"]
.map(String::from)
.to_vec();
let items = create_diff_items(&config, &Default::default(), &diff, 0, false, None);
assert!(
!items
.iter()
.any(|item| matches!(item.data, ItemData::HunkLine { .. }))
);
}
#[test]
fn an_empty_diff_does_not_run_the_renderer() {
let dir = temp_dir::TempDir::new().unwrap();
let ran = dir.child("ran");
let mut config = crate::config::init_test_config().unwrap();
config.general.diff_renderer.command = vec![
"sh".into(),
"-c".into(),
r#"touch "$0""#.into(),
ran.to_string_lossy().into_owned(),
];
let items = create_diff_items(&config, &Default::default(), &diff_from(""), 0, false, None);
assert!(items.is_empty());
assert!(!ran.exists(), "the renderer ran");
}
#[test]
fn a_line_wrapping_to_several_rows_stays_one_line() {
let diff = diff_from(DIFF);
let row = "\x1b]1717;1;a;2;;f.rs\x1b\\part\n";
let lines = parse_ansi_lines(&format!("\x1b]1717;1\x1b\\\n{}", row.repeat(3))).lines;
let items = rendered_diff_items(&diff, &lines, 0, false, None, false);
assert_eq!(
hunk_line_rows(&items),
vec![(2, false), (3, true), (3, true)]
);
}
#[test]
fn consecutive_rows_for_different_lines_are_both_selectable() {
let diff = diff_from(DIFF);
let lines = parse_ansi_lines(
"\x1b]1717;1\x1b\\\n\
\x1b]1717;1;a;2;;f.rs\x1b\\+added\n\
\x1b]1717;1;c;1;;f.rs\x1b\\ keep\n",
)
.lines;
let items = rendered_diff_items(&diff, &lines, 0, false, None, false);
assert_eq!(hunk_line_rows(&items), vec![(2, false), (2, true)]);
}
}