use super::{Area, Change, PATCH_LIMIT, command, output};
use crate::model::language;
use bezel::theme::HighlightKind;
use std::{
collections::HashSet,
ffi::OsString,
io::Read as _,
ops::Range,
path::{Path, PathBuf},
sync::Arc,
};
type Spans = Vec<(Range<usize>, HighlightKind)>;
const LINE_LIMIT: usize = 4096;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Kind {
Added,
Removed,
Context,
Hunk,
Meta,
}
pub struct Line {
pub kind: Kind,
pub old: Option<usize>,
pub new: Option<usize>,
pub text: String,
pub spans: Spans,
}
#[derive(Default)]
pub struct Preview {
pub patch: String,
pub lines: Vec<Line>,
path: PathBuf,
old: Option<String>,
new: Option<String>,
}
impl Preview {
pub fn plain(patch: String) -> Arc<Self> {
Arc::new(Self::build(Path::new(""), patch, None, None))
}
pub fn load(root: &Path, file: &Change, patch: String, previous: Arc<Self>) -> Arc<Self> {
let (old, new) = if language::of(&file.path).is_some() {
versions(root, file)
} else {
(None, None)
};
if previous.path == file.path
&& previous.patch == patch
&& previous.old == old
&& previous.new == new
{
return previous;
}
Arc::new(Self::build(&file.path, patch, old, new))
}
pub fn visible_rows(&self, collapsed: &HashSet<String>) -> Vec<usize> {
let mut hidden = false;
self.lines
.iter()
.enumerate()
.filter_map(|(ix, line)| {
if line.is_hunk() {
hidden = collapsed.contains(&line.text);
return Some(ix);
}
(!hidden
&& !line.is_patch_header()
&& (line.kind != Kind::Meta || !line.text.is_empty()))
.then_some(ix)
})
.collect()
}
pub fn hunk_label(&self, ix: usize) -> String {
let line = &self.lines[ix];
let Some(((old, old_count), (new, new_count))) = hunk(&line.text) else {
return "Conflict changes".into();
};
let (start, count) = if new_count == 0 {
(old, old_count)
} else {
(new, new_count)
};
let body = self.lines[ix + 1..]
.iter()
.take_while(|line| !line.is_hunk());
let (mut added, mut removed) = (0, 0);
for line in body {
added += usize::from(line.kind == Kind::Added);
removed += usize::from(line.kind == Kind::Removed);
}
let location = match count {
0 | 1 => format!("Line {start}"),
_ => format!("Lines {start}–{}", start + count - 1),
};
format!("{location} · +{added} −{removed}")
}
pub fn summary(&self) -> &'static str {
if self.patch.contains("rename from ") {
"File renamed"
} else if self.patch.contains("copy from ") {
"File copied"
} else if self.patch.contains("new file mode ") {
"Empty file added"
} else if self.patch.contains("deleted file mode ") {
"Empty file deleted"
} else if self.patch.contains("old mode ") {
"File permissions changed"
} else {
"No textual changes"
}
}
fn build(path: &Path, patch: String, old: Option<String>, new: Option<String>) -> Self {
let before = old.as_deref().map(|text| Source::new(text, path));
let after = new.as_deref().map(|text| Source::new(text, path));
let lines = lines(&patch, before.as_ref(), after.as_ref());
Self {
patch,
lines,
path: path.to_owned(),
old,
new,
}
}
}
impl Line {
pub fn is_hunk(&self) -> bool {
self.kind == Kind::Hunk || (self.kind == Kind::Meta && self.text.starts_with("@@@ "))
}
fn is_patch_header(&self) -> bool {
self.kind == Kind::Meta
&& [
"diff --git ",
"diff --cc ",
"diff --combined ",
"index ",
"--- ",
"+++ ",
"new file mode ",
"deleted file mode ",
"old mode ",
"new mode ",
"similarity index ",
"dissimilarity index ",
"rename from ",
"rename to ",
"copy from ",
"copy to ",
]
.iter()
.any(|prefix| self.text.starts_with(prefix))
}
}
fn versions(root: &Path, file: &Change) -> (Option<String>, Option<String>) {
let blob = |revision: &str, path: &Path| {
let mut spec = OsString::from(revision);
spec.push(path);
let mut git = command(root);
git.args(["cat-file", "blob"]).arg(spec);
let (exit, bytes, truncated) = output(git).ok()?;
(exit.success() && !truncated && !bytes.contains(&0))
.then(|| String::from_utf8(bytes).ok())
.flatten()
};
let working = || {
let path = root.join(&file.path);
if !std::fs::symlink_metadata(&path).ok()?.is_file() {
return None;
}
let mut bytes = Vec::new();
std::fs::File::open(path)
.ok()?
.take(PATCH_LIMIT + 1)
.read_to_end(&mut bytes)
.ok()?;
(bytes.len() as u64 <= PATCH_LIMIT && !bytes.contains(&0))
.then(|| String::from_utf8(bytes).ok())
.flatten()
};
match file.area {
Area::Staged => (
blob("HEAD:", file.original.as_deref().unwrap_or(&file.path)),
blob(":", &file.path),
),
Area::Unstaged => (blob(":", &file.path), working()),
Area::Untracked => (None, working()),
}
}
struct Source<'a> {
text: &'a str,
offsets: Vec<Range<usize>>,
spans: Spans,
}
impl<'a> Source<'a> {
fn new(text: &'a str, path: &Path) -> Self {
let mut offset = 0;
let offsets = text
.split_inclusive('\n')
.map(|line| {
let start = offset;
offset += line.len();
let line = line.strip_suffix('\n').unwrap_or(line);
let line = line.strip_suffix('\r').unwrap_or(line);
start..start + line.len()
})
.collect();
let spans = language::spans(path, text).unwrap_or_default();
Self {
text,
offsets,
spans,
}
}
fn spans(&self, number: usize, code: &str) -> Spans {
let Some(range) = number.checked_sub(1).and_then(|ix| self.offsets.get(ix)) else {
return Vec::new();
};
if &self.text[range.clone()] != code {
return Vec::new();
}
let start = self
.spans
.partition_point(|(span, _)| span.end <= range.start);
self.spans[start..]
.iter()
.take_while(|(span, _)| span.start < range.end)
.map(|(span, kind)| {
(
span.start.max(range.start) - range.start
..span.end.min(range.end) - range.start,
*kind,
)
})
.collect()
}
}
fn hunk(line: &str) -> Option<((usize, usize), (usize, usize))> {
fn side(value: &str, prefix: char) -> Option<(usize, usize)> {
let mut parts = value.strip_prefix(prefix)?.split(',');
let start = parts.next()?.parse().ok()?;
let count = parts.next().map(str::parse).transpose().ok()?.unwrap_or(1);
Some((start, count))
}
let mut parts = line.strip_prefix("@@ ")?.split_whitespace();
let old = side(parts.next()?, '-')?;
let new = side(parts.next()?, '+')?;
(parts.next()? == "@@").then_some((old, new))
}
fn lines(patch: &str, before: Option<&Source<'_>>, after: Option<&Source<'_>>) -> Vec<Line> {
let (mut old, mut new, mut old_left, mut new_left) = (0, 0, 0, 0);
patch
.lines()
.map(|line| {
if let Some(((o, oc), (n, nc))) = hunk(line) {
(old, new, old_left, new_left) = (o, n, oc, nc);
return display(Kind::Hunk, line, None, None, Vec::new());
}
let (kind, code, old_number, new_number, source, number) = match line.as_bytes().first()
{
Some(b'-') if old_left > 0 => {
let number = old;
old += 1;
old_left -= 1;
(
Kind::Removed,
&line[1..],
Some(number),
None,
before,
number,
)
}
Some(b'+') if new_left > 0 => {
let number = new;
new += 1;
new_left -= 1;
(Kind::Added, &line[1..], None, Some(number), after, number)
}
Some(b' ') if old_left > 0 && new_left > 0 => {
let (o, n) = (old, new);
old += 1;
new += 1;
old_left -= 1;
new_left -= 1;
(Kind::Context, &line[1..], Some(o), Some(n), after, n)
}
_ => {
if !line.starts_with("\\ No newline") {
old_left = 0;
new_left = 0;
}
return display(Kind::Meta, line, None, None, Vec::new());
}
};
let spans = source
.map(|source| source.spans(number, code))
.unwrap_or_default();
display(kind, code, old_number, new_number, spans)
})
.collect()
}
fn display(kind: Kind, raw: &str, old: Option<usize>, new: Option<usize>, spans: Spans) -> Line {
let cut = raw
.char_indices()
.nth(LINE_LIMIT)
.map_or(raw.len(), |(ix, _)| ix);
let mut text = String::new();
let mut positions = vec![0; cut + 1];
for (ix, ch) in raw[..cut].char_indices() {
positions[ix] = text.len();
if ch == '\t' {
text.push_str(" ");
} else {
text.push(ch);
}
positions[ix + ch.len_utf8()] = text.len();
}
let spans = spans
.into_iter()
.filter_map(|(range, kind)| {
let end = range.end.min(cut);
(range.start < end).then(|| (positions[range.start]..positions[end], kind))
})
.collect();
if cut < raw.len() {
text.push_str(" … [line truncated]");
}
Line {
kind,
old,
new,
text,
spans,
}
}
#[cfg(test)]
#[path = "../../../tests/unit/git_preview.rs"]
mod tests;