use std::collections::HashMap;
use std::ops::Range;
use ratatui::text::Line;
use ropey::Rope;
use crate::document::visual_cache::VisualRowCache;
use crate::document::ParsedDoc;
use crate::ui::line_render::visual_rows_for_line;
use super::hunk::Decision;
use super::state::DiffState;
#[derive(Debug, Clone)]
pub struct DiffVisualLine {
pub source: DiffLineSource,
pub rope_line: usize,
pub hunk_idx: Option<usize>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiffLineSource {
Context,
OldDelete,
NewAdd,
ContextRendered,
Decision,
}
#[derive(Default)]
pub struct DiffLayoutCache {
lines: Option<Vec<DiffVisualLine>>,
row_caches: Vec<VisualRowCache>,
rendered_index: Option<HashMap<usize, usize>>,
}
const ROW_CACHE_CAP: usize = 2;
impl DiffState {
fn build_visual_lines(&self) -> Vec<DiffVisualLine> {
let mut out: Vec<DiffVisualLine> = Vec::new();
let new_rope = self.new_buffer.rope();
let new_lines = new_rope.len_lines();
let mut new_cursor: usize = 0;
for (i, h) in self.hunks.iter().enumerate() {
while new_cursor < h.new_lines.start && new_cursor < new_lines {
out.push(DiffVisualLine {
source: DiffLineSource::Context,
rope_line: new_cursor,
hunk_idx: None,
});
new_cursor += 1;
}
new_cursor = h.new_lines.end;
for l in h.old_lines.clone() {
out.push(DiffVisualLine {
source: DiffLineSource::OldDelete,
rope_line: l,
hunk_idx: Some(i),
});
}
out.push(DiffVisualLine {
source: DiffLineSource::Decision,
rope_line: 0,
hunk_idx: Some(i),
});
for l in h.new_lines.clone() {
out.push(DiffVisualLine {
source: DiffLineSource::NewAdd,
rope_line: l,
hunk_idx: Some(i),
});
}
}
while new_cursor < new_lines {
out.push(DiffVisualLine {
source: DiffLineSource::Context,
rope_line: new_cursor,
hunk_idx: None,
});
new_cursor += 1;
}
out
}
fn build_visual_lines_rendered(&self, parsed: &ParsedDoc) -> Vec<DiffVisualLine> {
let total_lines = self.new_buffer.rope().len_lines();
let (spans, owners) = block_spans(self, parsed, total_lines);
if spans.is_empty() {
return self.build_visual_lines();
}
let mut out: Vec<DiffVisualLine> = Vec::new();
let mut i = 0usize;
while i < spans.len() {
if spans[i].touched {
let start = i;
let mut j = i;
while j < spans.len() && spans[j].touched {
j += 1;
}
let region = spans[start].lines.start..spans[j - 1].lines.end;
self.emit_raw_region(®ion, &owners, &mut out);
i = j;
} else {
self.emit_boundary_hunks(spans[i].lines.start, &owners, &mut out);
for row in spans[i].rows.clone() {
out.push(DiffVisualLine {
source: DiffLineSource::ContextRendered,
rope_line: row,
hunk_idx: None,
});
}
i += 1;
}
}
self.emit_boundary_hunks(total_lines, &owners, &mut out);
debug_assert!(
(0..self.hunks.len()).all(|hi| out
.iter()
.filter(|l| l.source == DiffLineSource::Decision && l.hunk_idx == Some(hi))
.count()
== 1),
"every hunk must appear exactly once in the rendered diff line list"
);
out
}
fn emit_raw_region(
&self,
region: &Range<usize>,
owners: &[HunkOwner],
out: &mut Vec<DiffVisualLine>,
) {
let mut new_cursor = region.start;
for (hi, owner) in owners.iter().enumerate() {
let in_region = match *owner {
HunkOwner::Region { start_line } => {
start_line >= region.start && start_line < region.end
}
HunkOwner::Boundary { line } => line >= region.start && line < region.end,
};
if !in_region {
continue;
}
let h = &self.hunks[hi];
let anchor = h.new_lines.start;
while new_cursor < anchor.min(region.end) {
out.push(DiffVisualLine {
source: DiffLineSource::Context,
rope_line: new_cursor,
hunk_idx: None,
});
new_cursor += 1;
}
self.emit_hunk(hi, out);
new_cursor = new_cursor.max(h.new_lines.end).max(anchor);
}
while new_cursor < region.end {
out.push(DiffVisualLine {
source: DiffLineSource::Context,
rope_line: new_cursor,
hunk_idx: None,
});
new_cursor += 1;
}
}
fn emit_boundary_hunks(
&self,
line: usize,
owners: &[HunkOwner],
out: &mut Vec<DiffVisualLine>,
) {
for (hi, owner) in owners.iter().enumerate() {
if matches!(*owner, HunkOwner::Boundary { line: l } if l == line) {
self.emit_hunk(hi, out);
}
}
}
fn emit_hunk(&self, hunk_idx: usize, out: &mut Vec<DiffVisualLine>) {
let h = &self.hunks[hunk_idx];
for l in h.old_lines.clone() {
out.push(DiffVisualLine {
source: DiffLineSource::OldDelete,
rope_line: l,
hunk_idx: Some(hunk_idx),
});
}
out.push(DiffVisualLine {
source: DiffLineSource::Decision,
rope_line: 0,
hunk_idx: Some(hunk_idx),
});
for l in h.new_lines.clone() {
out.push(DiffVisualLine {
source: DiffLineSource::NewAdd,
rope_line: l,
hunk_idx: Some(hunk_idx),
});
}
}
pub(crate) fn with_layout<R>(
&self,
width: usize,
f: impl FnOnce(&[DiffVisualLine], &VisualRowCache) -> R,
) -> R {
let width = width.max(1);
let mut cache = self.layout.borrow_mut();
self.ensure_layout(&mut cache, width);
let lines = cache.lines.as_ref().expect("lines built above");
let rc = cache.row_caches.first().expect("row cache built above");
f(lines, rc)
}
pub(crate) fn with_layout_index<R>(
&self,
width: usize,
f: impl FnOnce(&[DiffVisualLine], &VisualRowCache, &HashMap<usize, usize>) -> R,
) -> R {
let width = width.max(1);
let mut cache = self.layout.borrow_mut();
self.ensure_layout(&mut cache, width);
if cache.rendered_index.is_none() {
let index = rendered_row_index(cache.lines.as_ref().expect("lines built above"));
cache.rendered_index = Some(index);
}
let lines = cache.lines.as_ref().expect("lines built above");
let rc = cache.row_caches.first().expect("row cache built above");
let index = cache.rendered_index.as_ref().expect("index built above");
f(lines, rc, index)
}
fn ensure_layout(&self, cache: &mut DiffLayoutCache, width: usize) {
if cache.lines.is_none() {
cache.lines = Some(match self.parsed_new.as_ref() {
Some(parsed) => self.build_visual_lines_rendered(parsed),
None => self.build_visual_lines(),
});
}
if let Some(pos) = cache.row_caches.iter().position(|c| c.width() == width) {
let entry = cache.row_caches.remove(pos);
cache.row_caches.insert(0, entry);
} else {
let built = {
let lines = cache.lines.as_ref().expect("lines built above");
VisualRowCache::build(lines.len(), width, |i| {
if lines[i].source == DiffLineSource::Decision {
1
} else if lines[i].source == DiffLineSource::ContextRendered {
self.parsed_new
.as_ref()
.and_then(|p| p.lines.get(lines[i].rope_line))
.map_or(1, |l| visual_rows_for_line(l, width))
} else {
let text = format!(
"{}{}",
line_marker(lines[i].source),
line_text(self, &lines[i])
);
visual_rows_for_line(&Line::from(text), width)
}
})
};
cache.row_caches.insert(0, built);
cache.row_caches.truncate(ROW_CACHE_CAP);
}
}
pub fn total_visual_rows(&self, width: usize) -> usize {
self.with_layout(width, |_, rc| rc.total())
}
pub fn focused_hunk_visual_row(&self, width: usize) -> usize {
let Some(focused_idx) = self.focused_idx() else {
return 0;
};
self.with_layout(width, |lines, rc| {
match lines.iter().position(|l| l.hunk_idx == Some(focused_idx)) {
Some(pos) => rc.before(pos),
None => 0,
}
})
}
pub fn invalidate_layout(&self) {
let mut cache = self.layout.borrow_mut();
cache.lines = None;
cache.rendered_index = None;
cache.row_caches.clear();
self.bump_layout_version();
}
}
fn rendered_row_index(lines: &[DiffVisualLine]) -> HashMap<usize, usize> {
lines
.iter()
.enumerate()
.filter(|(_, l)| l.source == DiffLineSource::ContextRendered)
.map(|(i, l)| (l.rope_line, i))
.collect()
}
#[derive(Debug, Clone)]
struct BlockSpan {
lines: Range<usize>,
rows: Range<usize>,
touched: bool,
}
#[derive(Debug, Clone, Copy)]
enum HunkOwner {
Region { start_line: usize },
Boundary { line: usize },
}
fn block_spans(
diff: &DiffState,
parsed: &ParsedDoc,
total_lines: usize,
) -> (Vec<BlockSpan>, Vec<HunkOwner>) {
let contents = parsed.source();
let mut spans: Vec<BlockSpan> = Vec::with_capacity(parsed.source_map.block_count());
let mut scanned = 0usize;
let mut block_line = 0usize;
for block_idx in 0..parsed.source_map.block_count() {
let range = parsed
.source_map
.original_range_for_block(block_idx)
.unwrap_or(scanned..scanned);
let start = range.start.min(contents.len());
if start > scanned {
block_line += contents.as_bytes()[scanned..start]
.iter()
.filter(|&&b| b == b'\n')
.count();
scanned = start;
}
let rows = if parsed.block_own_line_count(block_idx) == 0 {
0..0
} else {
parsed.source_map.rendered_lines_for_block(block_idx)
};
spans.push(BlockSpan {
lines: block_line..block_line,
rows,
touched: false,
});
}
for i in 0..spans.len() {
let end = spans
.get(i + 1)
.map_or(total_lines, |next| next.lines.start)
.max(spans[i].lines.start);
spans[i].lines.end = end;
}
if let Some(first) = spans.first_mut() {
first.lines.start = 0;
}
debug_assert!(
spans.first().is_none_or(|s| s.lines.start == 0)
&& spans.windows(2).all(|w| w[0].lines.end == w[1].lines.start)
&& spans.last().is_none_or(|s| s.lines.end == total_lines),
"block spans must partition every source line of the new side"
);
let mut owners: Vec<HunkOwner> = Vec::with_capacity(diff.hunks.len());
for h in &diff.hunks {
let mut first_touched: Option<usize> = None;
if h.new_lines.is_empty() {
let point = h.new_lines.start;
for (bi, sp) in spans.iter().enumerate() {
if sp.lines.start < point && point < sp.lines.end {
first_touched = Some(bi);
break;
}
}
} else {
for (bi, sp) in spans.iter().enumerate() {
if sp.lines.start < h.new_lines.end && sp.lines.end > h.new_lines.start {
first_touched.get_or_insert(bi);
}
}
}
match first_touched {
Some(bi) => owners.push(HunkOwner::Region {
start_line: spans[bi].lines.start,
}),
None if h.new_lines.is_empty() => owners.push(HunkOwner::Boundary {
line: h.new_lines.start,
}),
None => {
debug_assert!(false, "every hunk must land in a raw region");
let bi = spans
.iter()
.rposition(|sp| sp.lines.start <= h.new_lines.start)
.unwrap_or(0);
if let Some(sp) = spans.get_mut(bi) {
sp.touched = true;
}
let start_line = spans.get(bi).map_or(0, |sp| sp.lines.start);
owners.push(HunkOwner::Region { start_line });
}
}
}
for (hi, owner) in owners.iter().enumerate() {
if !matches!(owner, HunkOwner::Region { .. }) {
continue;
}
let h = &diff.hunks[hi];
for sp in spans.iter_mut() {
let hit = if h.new_lines.is_empty() {
sp.lines.start < h.new_lines.start && h.new_lines.start < sp.lines.end
} else {
sp.lines.start < h.new_lines.end && sp.lines.end > h.new_lines.start
};
if hit {
sp.touched = true;
}
}
}
(spans, owners)
}
pub fn line_text(diff: &DiffState, dvl: &DiffVisualLine) -> String {
if dvl.source == DiffLineSource::Decision {
let dec = dvl
.hunk_idx
.and_then(|hi| diff.decisions.get(hi).copied())
.unwrap_or(Decision::Pending);
return decision_line_text(dec).to_owned();
}
if dvl.source == DiffLineSource::ContextRendered {
return String::new();
}
let rope: &Rope = match dvl.source {
DiffLineSource::Context | DiffLineSource::NewAdd => diff.new_buffer.rope(),
DiffLineSource::OldDelete => &diff.old_rope,
DiffLineSource::Decision | DiffLineSource::ContextRendered => {
unreachable!("handled above")
}
};
if dvl.rope_line >= rope.len_lines() {
return String::new();
}
let raw = rope.line(dvl.rope_line).to_string();
raw.trim_end_matches('\n').to_owned()
}
pub fn line_marker(source: DiffLineSource) -> &'static str {
match source {
DiffLineSource::OldDelete => "- ",
DiffLineSource::NewAdd => "+ ",
DiffLineSource::Context => " ",
DiffLineSource::ContextRendered => "",
DiffLineSource::Decision => "",
}
}
pub fn decision_line_text(decision: Decision) -> &'static str {
match decision {
Decision::Pending => "[ ]",
Decision::Accepted => "[Y] Accepted",
Decision::Rejected => "[N] Rejected",
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Theme;
fn theme() -> &'static Theme {
Box::leak(Box::new(Theme::default()))
}
fn rendered(old: &str, new: &str) -> DiffState {
let mut st = DiffState::new(old, new).expect("non-empty diff");
let parsed = ParsedDoc::build(new, theme(), true, 20);
st.set_rendered_parse(Some(parsed));
st
}
fn lines_of(st: &DiffState) -> Vec<DiffVisualLine> {
st.with_layout(80, |lines, _| lines.to_vec())
}
fn rendered_text(st: &DiffState, dvl: &DiffVisualLine) -> String {
st.parsed_new
.as_ref()
.and_then(|p| p.lines.get(dvl.rope_line))
.map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
.unwrap_or_default()
}
fn rendered_rows(st: &DiffState) -> Vec<String> {
lines_of(st)
.iter()
.filter(|l| l.source == DiffLineSource::ContextRendered)
.map(|l| rendered_text(st, l))
.collect()
}
fn raw_rows(st: &DiffState, want: DiffLineSource) -> Vec<String> {
lines_of(st)
.iter()
.filter(|l| l.source == want)
.map(|l| line_text(st, l))
.collect()
}
#[test]
fn a_change_in_one_paragraph_leaves_the_others_rendered() {
let old = "# Title\n\nAlpha.\n\nBravo.\n\nCharlie.\n";
let new = "# Title\n\nAlpha.\n\nBRAVO!\n\nCharlie.\n";
let st = rendered(old, new);
let rows = rendered_rows(&st);
assert!(rows.iter().any(|r| r.contains("Title")), "{rows:?}");
assert!(rows.iter().any(|r| r.contains("Alpha.")), "{rows:?}");
assert!(rows.iter().any(|r| r.contains("Charlie.")), "{rows:?}");
assert!(!rows.iter().any(|r| r.contains('#')), "{rows:?}");
assert_eq!(raw_rows(&st, DiffLineSource::OldDelete), vec!["Bravo."]);
assert_eq!(raw_rows(&st, DiffLineSource::NewAdd), vec!["BRAVO!"]);
}
#[test]
fn a_delete_on_a_block_boundary_lands_between_two_rendered_runs() {
let old = "Alpha.\n\nBravo.\n\nCharlie.\n";
let new = "Alpha.\n\nCharlie.\n";
let st = rendered(old, new);
let lines = lines_of(&st);
assert!(!raw_rows(&st, DiffLineSource::OldDelete).is_empty());
assert!(lines.iter().any(|l| l.source == DiffLineSource::Decision));
let rows = rendered_rows(&st);
assert!(rows.iter().any(|r| r.contains("Alpha.")), "{rows:?}");
assert!(rows.iter().any(|r| r.contains("Charlie.")), "{rows:?}");
assert!(
raw_rows(&st, DiffLineSource::Context)
.iter()
.all(|r| r.is_empty()),
"{:?}",
raw_rows(&st, DiffLineSource::Context)
);
}
#[test]
fn a_changed_table_row_keeps_one_hunk_per_row_and_one_raw_region() {
let old = "| a | b |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |\n";
let new = "| a | b |\n|---|---|\n| 1 | X |\n| 3 | 4 |\n";
let st = rendered(old, new);
assert_eq!(st.hunks.len(), 1);
let ctx = raw_rows(&st, DiffLineSource::Context);
assert!(ctx.iter().any(|r| r.contains("| 3 | 4 |")), "{ctx:?}");
assert_eq!(raw_rows(&st, DiffLineSource::NewAdd), vec!["| 1 | X |"]);
}
#[test]
fn a_change_confined_to_an_html_comment_is_still_reviewable() {
let old = "Alpha.\n\n<!-- note: one -->\n\nBravo.\n";
let new = "Alpha.\n\n<!-- note: two -->\n\nBravo.\n";
let st = rendered(old, new);
assert_eq!(
raw_rows(&st, DiffLineSource::OldDelete),
vec!["<!-- note: one -->"]
);
assert_eq!(
raw_rows(&st, DiffLineSource::NewAdd),
vec!["<!-- note: two -->"]
);
assert_eq!(
lines_of(&st)
.iter()
.filter(|l| l.source == DiffLineSource::Decision)
.count(),
1
);
}
#[test]
fn a_change_in_a_collapsed_blank_run_is_still_reviewable() {
let old = "Alpha.\n\n\n\nBravo.\n";
let new = "Alpha.\n\n\n\nBRAVO!\n";
let mut st = DiffState::new(old, new).expect("non-empty diff");
st.set_rendered_parse(Some(ParsedDoc::build(new, theme(), false, 20)));
assert_eq!(raw_rows(&st, DiffLineSource::OldDelete), vec!["Bravo."]);
assert_eq!(raw_rows(&st, DiffLineSource::NewAdd), vec!["BRAVO!"]);
}
const MIXED_NEW: &str = "# Title\n\nAlpha text.\n\n<!-- hidden -->\n\n| a | b |\n|---|---|\n| 1 | 2 |\n\nOmega.\n\n\n";
#[test]
fn block_spans_partition_every_source_line() {
let old = MIXED_NEW.replace("Alpha text.", "Alpha.");
let st = rendered(&old, MIXED_NEW);
let parsed = st.parsed_new.as_ref().expect("parse installed");
let total = st.new_buffer.rope().len_lines();
let (spans, _) = block_spans(&st, parsed, total);
assert_eq!(spans.first().expect("blocks").lines.start, 0);
for w in spans.windows(2) {
assert_eq!(w[0].lines.end, w[1].lines.start, "{spans:?}");
}
assert_eq!(spans.last().expect("blocks").lines.end, total);
}
#[test]
fn every_hunk_appears_exactly_once_in_the_line_list() {
let old = "# Title\n\nAlpha.\n\n<!-- hidden -->\n\n| a | b |\n|---|---|\n| 1 | 2 |\n\nOmega.\n\n\n";
let new = "# TITLE\n\nAlpha.\n\n<!-- shown -->\n\n| a | b |\n|---|---|\n| 1 | 9 |\n\nOmega!\n\n\n";
let st = rendered(old, new);
assert!(st.hunks.len() >= 3, "{} hunks", st.hunks.len());
let lines = lines_of(&st);
for hi in 0..st.hunks.len() {
let dividers = lines
.iter()
.filter(|l| l.source == DiffLineSource::Decision && l.hunk_idx == Some(hi))
.count();
assert_eq!(dividers, 1, "hunk {hi} emitted {dividers} times");
}
}
#[test]
fn rendered_totals_are_stable_across_repeated_and_multi_width_queries() {
let st = rendered(
"# Title\n\nAlpha.\n\nBravo.\n",
"# Title\n\nAlpha!\n\nBravo.\n",
);
let a1 = st.total_visual_rows(80);
let b1 = st.total_visual_rows(40);
assert_eq!(a1, st.total_visual_rows(80));
assert_eq!(b1, st.total_visual_rows(40));
assert!(b1 >= a1);
}
#[test]
fn focused_hunk_row_lands_on_the_hunk_after_rendered_context() {
let st = rendered(
"# Title\n\nAlpha.\n\nBravo.\n",
"# Title\n\nAlpha.\n\nBRAVO!\n",
);
let row = st.focused_hunk_visual_row(80);
let (idx, sub) = st.with_layout(80, |_, rc| rc.find_visual_row(row));
assert_eq!(sub, 0);
let lines = lines_of(&st);
assert_eq!(lines[idx].hunk_idx, st.focused_idx());
assert!(row > 0, "rendered context precedes the hunk");
}
#[test]
fn a_new_side_truncated_to_empty_still_shows_its_deletion() {
let old = "# Title\n\nAlpha.\n";
let st = rendered(old, "");
assert_eq!(st.hunks.len(), 1);
let lines = lines_of(&st);
assert!(!lines.is_empty(), "a truncated review must not be blank");
assert_eq!(
lines
.iter()
.filter(|l| l.source == DiffLineSource::Decision)
.count(),
1,
"the deletion needs its decision divider: {lines:?}"
);
let deleted = raw_rows(&st, DiffLineSource::OldDelete);
assert!(deleted.iter().any(|r| r == "# Title"), "{deleted:?}");
assert!(deleted.iter().any(|r| r == "Alpha."), "{deleted:?}");
let raw = DiffState::new(old, "").expect("non-empty diff");
assert_eq!(st.total_visual_rows(80), raw.total_visual_rows(80));
assert!(st.total_visual_rows(80) > 0);
}
#[test]
fn the_rendered_row_memo_follows_the_layout_it_was_built_from() {
let old = "# Title\n\nAlpha.\n\nBravo.\n";
let new = "# Title\n\nAlpha.\n\nBRAVO!\n";
let mut st = rendered(old, new);
let before = st.with_layout_index(80, |lines, _, index| {
for (&row, &pos) in index {
assert_eq!(lines[pos].source, DiffLineSource::ContextRendered);
assert_eq!(lines[pos].rope_line, row);
}
index.len()
});
assert!(before > 0, "the clean blocks must contribute rows");
assert_eq!(st.with_layout_index(80, |_, _, index| index.len()), before);
st.set_rendered_parse(None);
assert_eq!(
st.with_layout_index(80, |_, _, index| index.len()),
0,
"a raw layout has no rendered rows, so the memo must be rebuilt empty"
);
}
#[test]
fn dropping_the_parse_restores_the_raw_layout() {
let old = "# Title\n\nAlpha.\n";
let new = "# Title\n\nALPHA.\n";
let raw = DiffState::new(old, new).expect("non-empty diff");
let mut st = rendered(old, new);
assert_ne!(st.total_visual_rows(80), raw.total_visual_rows(80));
st.set_rendered_parse(None);
assert_eq!(st.total_visual_rows(80), raw.total_visual_rows(80));
}
fn diff_with_leading_context(n: usize) -> DiffState {
let mut old = String::new();
for i in 0..n {
old.push_str(&format!("ctx{i}\n"));
}
let mut new = old.clone();
old.push_str("before\n");
new.push_str("AFTER\n");
old.push_str("tail\n");
new.push_str("tail\n");
DiffState::new(&old, &new).expect("non-empty diff")
}
#[test]
fn focused_hunk_row_skips_leading_context() {
let st = diff_with_leading_context(5);
assert_eq!(st.focused_hunk_visual_row(80), 5);
}
#[test]
fn total_visual_rows_counts_every_stacked_line() {
let st = diff_with_leading_context(5);
assert_eq!(st.total_visual_rows(80), 10);
}
#[test]
fn cached_total_survives_repeated_and_multi_width_queries() {
let st = diff_with_leading_context(3);
let a1 = st.total_visual_rows(80);
let b1 = st.total_visual_rows(40);
let a2 = st.total_visual_rows(80);
let b2 = st.total_visual_rows(40);
assert_eq!(a1, a2);
assert_eq!(b1, b2);
assert!(b1 >= a1);
}
#[test]
fn invalidate_layout_forces_rebuild() {
let st = diff_with_leading_context(4);
let before = st.total_visual_rows(80);
st.invalidate_layout();
assert_eq!(st.total_visual_rows(80), before);
}
}