use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use crate::git::{DiffContent, FileDiff, Hunk, LineKind};
pub fn seen_hunk_fingerprint(
seen: &BTreeMap<(PathBuf, usize), u64>,
path: &Path,
old_start: usize,
) -> Option<u64> {
seen.iter()
.find(|((p, o), _)| *o == old_start && p.as_path() == path)
.map(|(_, fp)| *fp)
}
pub fn hunk_fingerprint(hunk: &Hunk) -> u64 {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut h = DefaultHasher::new();
for line in &hunk.lines {
let tag: u8 = match line.kind {
LineKind::Context => 0,
LineKind::Added => 1,
LineKind::Deleted => 2,
};
tag.hash(&mut h);
line.content.hash(&mut h);
line.has_trailing_newline.hash(&mut h);
}
h.finish()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CursorPlacement {
Centered,
Top,
}
impl CursorPlacement {
pub fn viewport_top(self, cursor: usize, total: usize, height: usize) -> usize {
if total <= height {
return 0;
}
let max_top = total - height;
let raw = match self {
CursorPlacement::Centered => cursor.saturating_sub(height / 2),
CursorPlacement::Top => cursor,
};
raw.min(max_top)
}
pub fn label(self) -> &'static str {
match self {
CursorPlacement::Centered => "center",
CursorPlacement::Top => "top",
}
}
}
#[derive(Debug, Default, Clone)]
pub struct ScrollLayout {
pub rows: Vec<RowKind>,
pub hunk_starts: Vec<usize>,
pub hunk_ranges: Vec<Vec<(usize, usize)>>,
pub hunk_fingerprints: Vec<Vec<Option<u64>>>,
pub file_first_hunk: Vec<Option<usize>>,
pub file_of_row: Vec<usize>,
pub change_runs: Vec<(usize, usize)>,
pub diff_line_numbers: Vec<Option<(Option<usize>, Option<usize>)>>,
pub max_line_number: usize,
}
#[derive(Debug, Clone)]
pub struct VisualIndex {
prefix: Vec<usize>,
#[allow(dead_code)]
pub body_width: Option<usize>,
}
impl VisualIndex {
pub fn build(layout: &ScrollLayout, files: &[FileDiff], body_width: Option<usize>) -> Self {
Self::from_heights(
body_width,
layout
.rows
.iter()
.map(|row| Self::row_visual_height(row, files, body_width)),
)
}
pub fn build_lines(lines: &[String], body_width: Option<usize>) -> Self {
Self::from_heights(
body_width,
lines
.iter()
.map(|line| Self::line_visual_height(line, body_width)),
)
}
fn from_heights<I>(body_width: Option<usize>, heights: I) -> Self
where
I: IntoIterator<Item = usize>,
{
let mut prefix = Vec::new();
prefix.push(0);
let mut acc = 0usize;
for h in heights {
acc += h;
prefix.push(acc);
}
Self { prefix, body_width }
}
pub fn visual_y(&self, row_idx: usize) -> usize {
self.prefix.get(row_idx).copied().unwrap_or(0)
}
pub fn visual_height(&self, row_idx: usize) -> usize {
match (self.prefix.get(row_idx), self.prefix.get(row_idx + 1)) {
(Some(&a), Some(&b)) => b - a,
_ => 1,
}
}
pub fn total_visual(&self) -> usize {
self.prefix.last().copied().unwrap_or(0)
}
pub fn logical_at(&self, y: usize) -> (usize, usize) {
if self.prefix.len() < 2 {
return (0, 0);
}
let total = self.total_visual();
if y >= total {
let last = self.prefix.len() - 2;
return (last, self.visual_height(last).saturating_sub(1));
}
let mut lo = 0usize;
let mut hi = self.prefix.len() - 1;
while lo < hi {
let mid = lo + (hi - lo) / 2;
if self.prefix[mid + 1] > y {
hi = mid;
} else {
lo = mid + 1;
}
}
let within = y - self.prefix[lo];
(lo, within)
}
fn row_visual_height(row: &RowKind, files: &[FileDiff], body_width: Option<usize>) -> usize {
let Some(width) = body_width else {
return 1;
};
let RowKind::DiffLine {
file_idx,
hunk_idx,
line_idx,
} = row
else {
return 1;
};
let Some(file) = files.get(*file_idx) else {
return 1;
};
let DiffContent::Text(hunks) = &file.content else {
return 1;
};
let Some(hunk) = hunks.get(*hunk_idx) else {
return 1;
};
let Some(line) = hunk.lines.get(*line_idx) else {
return 1;
};
let cells = unicode_width::UnicodeWidthStr::width(line.content.as_str());
if cells == 0 {
1
} else {
cells.div_ceil(width.max(1))
}
}
fn line_visual_height(line: &str, body_width: Option<usize>) -> usize {
let Some(width) = body_width else {
return 1;
};
use unicode_width::UnicodeWidthChar;
if line.is_empty() {
return 1;
}
let mut rows = 1usize;
let mut chunk_cells = 0usize;
for ch in line.chars() {
let ch_cells = ch.width().unwrap_or(0);
if chunk_cells > 0 && chunk_cells + ch_cells > width {
rows += 1;
chunk_cells = 0;
}
chunk_cells += ch_cells;
}
rows
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RowKind {
FileHeader { file_idx: usize },
HunkHeader { file_idx: usize, hunk_idx: usize },
DiffLine {
file_idx: usize,
hunk_idx: usize,
line_idx: usize,
},
BinaryNotice { file_idx: usize },
Spacer,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HunkAnchor {
pub path: PathBuf,
pub hunk_old_start: usize,
}
pub(crate) fn build_scroll_layout(
files: &[FileDiff],
seen_hunks: &BTreeMap<(PathBuf, usize), u64>,
) -> ScrollLayout {
let mut layout = ScrollLayout {
file_first_hunk: vec![None; files.len()],
..ScrollLayout::default()
};
let mut max_line_number: usize = 0;
for (file_idx, file) in files.iter().enumerate() {
let mut file_hunk_ranges = Vec::new();
let mut file_hunk_fingerprints = Vec::new();
let header_row = layout.rows.len();
layout.rows.push(RowKind::FileHeader { file_idx });
layout.diff_line_numbers.push(None);
match &file.content {
DiffContent::Binary => {
let notice_row = layout.rows.len();
layout.rows.push(RowKind::BinaryNotice { file_idx });
layout.diff_line_numbers.push(None);
layout.file_first_hunk[file_idx] = Some(notice_row);
}
DiffContent::Text(hunks) => {
if hunks.is_empty() {
layout.file_first_hunk[file_idx] = Some(header_row);
} else {
let first_hunk_row = layout.rows.len();
layout.file_first_hunk[file_idx] = Some(first_hunk_row);
for (hunk_idx, hunk) in hunks.iter().enumerate() {
let row = layout.rows.len();
layout.rows.push(RowKind::HunkHeader { file_idx, hunk_idx });
layout.diff_line_numbers.push(None);
layout.hunk_starts.push(row);
let marked_fp =
seen_hunk_fingerprint(seen_hunks, &file.path, hunk.old_start);
let current_fp = marked_fp.map(|_| hunk_fingerprint(hunk));
file_hunk_fingerprints.push(current_fp);
let is_seen = matches!(
(marked_fp, current_fp),
(Some(marked), Some(current)) if marked == current
);
if !is_seen {
let mut old = hunk.old_start;
let mut new = hunk.new_start;
for (line_idx, line) in hunk.lines.iter().enumerate() {
layout.rows.push(RowKind::DiffLine {
file_idx,
hunk_idx,
line_idx,
});
let pair = match line.kind {
LineKind::Context => {
let p = (Some(old), Some(new));
old += 1;
new += 1;
p
}
LineKind::Added => {
let p = (None, Some(new));
new += 1;
p
}
LineKind::Deleted => {
let p = (Some(old), None);
old += 1;
p
}
};
if let Some(n) = pair.0
&& n > max_line_number
{
max_line_number = n;
}
if let Some(n) = pair.1
&& n > max_line_number
{
max_line_number = n;
}
layout.diff_line_numbers.push(Some(pair));
}
}
file_hunk_ranges.push((row, layout.rows.len()));
}
}
}
}
layout.hunk_ranges.push(file_hunk_ranges);
layout.hunk_fingerprints.push(file_hunk_fingerprints);
layout.rows.push(RowKind::Spacer);
layout.diff_line_numbers.push(None);
}
layout.max_line_number = max_line_number.max(10);
layout.file_of_row = layout
.rows
.iter()
.scan(0usize, |last_file, row| {
let f = match row {
RowKind::FileHeader { file_idx } => *file_idx,
RowKind::HunkHeader { file_idx, .. } => *file_idx,
RowKind::DiffLine { file_idx, .. } => *file_idx,
RowKind::BinaryNotice { file_idx } => *file_idx,
RowKind::Spacer => *last_file,
};
*last_file = f;
Some(f)
})
.collect();
let mut current_run_start: Option<usize> = None;
for (row_idx, row) in layout.rows.iter().enumerate() {
let is_change = match row {
RowKind::DiffLine {
file_idx,
hunk_idx,
line_idx,
} => match &files[*file_idx].content {
DiffContent::Text(hunks) => {
hunks[*hunk_idx].lines[*line_idx].kind != LineKind::Context
}
DiffContent::Binary => false,
},
_ => false,
};
match (is_change, current_run_start) {
(true, None) => {
current_run_start = Some(row_idx);
}
(false, Some(start)) => {
layout.change_runs.push((start, row_idx));
current_run_start = None;
}
_ => {}
}
}
if let Some(start) = current_run_start {
layout.change_runs.push((start, layout.rows.len()));
}
layout
}