use ratatui::text::Line;
use ropey::Rope;
use crate::document::visual_cache::VisualRowCache;
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,
Decision,
}
#[derive(Default)]
pub struct DiffLayoutCache {
lines: Option<Vec<DiffVisualLine>>,
row_caches: Vec<VisualRowCache>,
}
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
}
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();
if cache.lines.is_none() {
cache.lines = Some(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 {
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);
}
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 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.row_caches.clear();
}
}
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();
}
let rope: &Rope = match dvl.source {
DiffLineSource::Context | DiffLineSource::NewAdd => diff.new_buffer.rope(),
DiffLineSource::OldDelete => &diff.old_rope,
DiffLineSource::Decision => 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::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::*;
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);
}
}