feat: add expandable diff excerpts with configurable context

This commit is contained in:
2026-10-11 01:45:13 +02:00
parent 5e0700bb8c
commit 704dce2539
8 changed files with 590 additions and 21 deletions
+340 -2
View File
@@ -123,6 +123,12 @@ pub struct AlignedRow {
pub kind: RowKind,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum DisplayRow {
Line(usize),
Fold { start: usize, end: usize },
}
#[derive(Debug)]
struct SelectorPosition {
category: usize,
@@ -156,6 +162,10 @@ pub struct DiffState {
pub left_before: usize,
pub left_after: usize,
pub rows: Vec<AlignedRow>,
pub display_rows: Vec<DisplayRow>,
excerpt_ranges: Vec<(usize, usize)>,
context_lines: usize,
expand_lines: usize,
pending: Option<Operation>,
request_kind: Option<RequestKind>,
visible: Vec<usize>,
@@ -187,6 +197,10 @@ impl DiffState {
left_before: 0,
left_after: 0,
rows: Vec::new(),
display_rows: Vec::new(),
excerpt_ranges: Vec::new(),
context_lines: 10,
expand_lines: 15,
pending: Some(Operation::Discover),
request_kind: Some(RequestKind::Catalog),
visible: Vec::new(),
@@ -318,7 +332,7 @@ impl DiffState {
self.top = if forward {
self.top
.saturating_add(1)
.min(self.rows.len().saturating_sub(1))
.min(self.display_rows.len().saturating_sub(1))
} else {
self.top.saturating_sub(1)
};
@@ -540,9 +554,11 @@ impl DiffState {
self.top = if input == Input::Home {
0
} else {
self.rows.len().saturating_sub(1)
self.display_rows.len().saturating_sub(1)
};
}
Input::Char('[') => self.expand_excerpt(false),
Input::Char(']') => self.expand_excerpt(true),
_ => {}
}
if let Some(files) = &mut self.files {
@@ -552,6 +568,8 @@ impl DiffState {
fn align_file(&mut self) {
self.rows.clear();
self.display_rows.clear();
self.excerpt_ranges.clear();
self.top = 0;
self.left_before = 0;
self.left_after = 0;
@@ -615,6 +633,125 @@ impl DiffState {
file.after.len(),
false,
);
self.reset_excerpts();
}
pub(crate) fn set_context_options(&mut self, context_lines: usize, expand_lines: usize) {
if self.context_lines == context_lines && self.expand_lines == expand_lines {
return;
}
let context_changed = self.context_lines != context_lines;
self.context_lines = context_lines;
self.expand_lines = expand_lines.max(1);
if context_changed {
self.reset_excerpts();
self.top = 0;
}
}
fn reset_excerpts(&mut self) {
self.excerpt_ranges.clear();
for (index, row) in self.rows.iter().enumerate() {
if row.kind == RowKind::Equal {
continue;
}
let start = index.saturating_sub(self.context_lines);
let end = index
.saturating_add(1)
.saturating_add(self.context_lines)
.min(self.rows.len());
if let Some(last) = self.excerpt_ranges.last_mut()
&& start <= last.1
{
last.1 = end;
} else {
self.excerpt_ranges.push((start, end));
}
}
self.rebuild_display_rows();
}
fn rebuild_display_rows(&mut self) {
let mut count = 0;
for index in 0..self.excerpt_ranges.len() {
let range = self.excerpt_ranges[index];
if count > 0 && range.0 <= self.excerpt_ranges[count - 1].1 {
self.excerpt_ranges[count - 1].1 = self.excerpt_ranges[count - 1].1.max(range.1);
} else {
self.excerpt_ranges[count] = range;
count += 1;
}
}
self.excerpt_ranges.truncate(count);
self.display_rows.clear();
if self.excerpt_ranges.is_empty() {
return;
}
let mut end = 0;
for &(start, next_end) in &self.excerpt_ranges {
if end < start {
self.display_rows.push(DisplayRow::Fold {
start: end,
end: start,
});
}
self.display_rows
.extend((start..next_end).map(DisplayRow::Line));
end = next_end;
}
if end < self.rows.len() {
self.display_rows.push(DisplayRow::Fold {
start: end,
end: self.rows.len(),
});
}
}
fn expand_excerpt(&mut self, below: bool) {
let Some(top) = self.display_rows.get(self.top).copied() else {
return;
};
let index = match top {
DisplayRow::Line(row) => self
.excerpt_ranges
.iter()
.position(|&(start, end)| start <= row && row < end),
DisplayRow::Fold { end, .. } => self
.excerpt_ranges
.iter()
.position(|&(start, _)| start == end)
.or_else(|| self.excerpt_ranges.len().checked_sub(1)),
};
let Some(index) = index else {
return;
};
let range = &mut self.excerpt_ranges[index];
let previous = *range;
if below {
range.1 = range
.1
.saturating_add(self.expand_lines)
.min(self.rows.len());
} else {
range.0 = range.0.saturating_sub(self.expand_lines);
}
if *range == previous {
return;
}
let anchor = match top {
DisplayRow::Line(row) => row,
DisplayRow::Fold { end, .. } if !below && end == previous.0 => range.0,
DisplayRow::Fold { start, .. } => start,
};
self.rebuild_display_rows();
self.top = self
.display_rows
.iter()
.position(|row| match *row {
DisplayRow::Line(index) => index == anchor,
DisplayRow::Fold { start, end } => start <= anchor && anchor < end,
})
.unwrap_or(0);
}
}
@@ -649,6 +786,207 @@ mod tests {
use super::*;
use crate::editor::{Editor, Effect, Mode};
fn excerpt_state(len: usize, changed: &[usize]) -> DiffState {
let mut state = DiffState::new();
state.phase = Phase::View;
state.focus = Focus::Before;
state.rows = (0..len)
.map(|index| AlignedRow {
before: Some(index),
after: Some(index + 3),
kind: if changed.contains(&index) {
RowKind::Changed
} else {
RowKind::Equal
},
})
.collect();
state.reset_excerpts();
state
}
#[test]
fn excerpts_cover_context_and_hidden_rows_once_without_reindexing() {
let state = excerpt_state(100, &[30, 70]);
assert_eq!(state.excerpt_ranges, [(20, 41), (60, 81)]);
assert_eq!(
state.display_rows[0],
DisplayRow::Fold { start: 0, end: 20 }
);
assert_eq!(
state.display_rows[22],
DisplayRow::Fold { start: 41, end: 60 }
);
assert_eq!(
state.display_rows[44],
DisplayRow::Fold {
start: 81,
end: 100
}
);
let mut covered = Vec::new();
for row in &state.display_rows {
match *row {
DisplayRow::Line(index) => {
covered.push(index);
assert_eq!(state.rows[index].before, Some(index));
assert_eq!(state.rows[index].after, Some(index + 3));
}
DisplayRow::Fold { start, end } => {
assert!(
state.rows[start..end]
.iter()
.all(|row| row.kind == RowKind::Equal)
);
covered.extend(start..end);
}
}
}
assert_eq!(covered, (0..100).collect::<Vec<_>>());
for index in [30, 70] {
assert_eq!(
state
.display_rows
.iter()
.filter(|&&row| row == DisplayRow::Line(index))
.count(),
1
);
}
}
#[test]
fn zero_context_boundaries_adjacent_changes_and_touching_ranges() {
let mut state = excerpt_state(60, &[0, 1, 30, 59]);
state.set_context_options(0, 15);
assert_eq!(state.excerpt_ranges, [(0, 2), (30, 31), (59, 60)]);
assert_eq!(
state.display_rows,
[
DisplayRow::Line(0),
DisplayRow::Line(1),
DisplayRow::Fold { start: 2, end: 30 },
DisplayRow::Line(30),
DisplayRow::Fold { start: 31, end: 59 },
DisplayRow::Line(59),
]
);
assert_eq!(excerpt_state(60, &[20, 41]).excerpt_ranges, [(10, 52)]);
assert_eq!(
excerpt_state(3, &[0, 1, 2]).display_rows,
[
DisplayRow::Line(0),
DisplayRow::Line(1),
DisplayRow::Line(2),
]
);
}
#[test]
fn expansion_anchors_source_rows_merges_and_clamps_both_directions() {
let mut state = excerpt_state(100, &[30, 70]);
state.top = 11;
state.handle(Input::Char('['));
assert_eq!(state.excerpt_ranges, [(5, 41), (60, 81)]);
assert_eq!(state.display_rows[state.top], DisplayRow::Line(30));
state.handle(Input::Char('['));
assert_eq!(state.excerpt_ranges, [(0, 41), (60, 81)]);
state.handle(Input::Char(']'));
assert_eq!(state.excerpt_ranges, [(0, 56), (60, 81)]);
state.handle(Input::Char(']'));
assert_eq!(state.excerpt_ranges, [(0, 81)]);
assert_eq!(state.display_rows[state.top], DisplayRow::Line(30));
state.handle(Input::Char(']'));
assert_eq!(state.excerpt_ranges, [(0, 96)]);
state.handle(Input::Char(']'));
state.handle(Input::Char(']'));
assert_eq!(state.excerpt_ranges, [(0, 100)]);
assert_eq!(state.display_rows[state.top], DisplayRow::Line(30));
}
#[test]
fn folds_choose_following_excerpt_or_previous_at_end() {
let mut state = excerpt_state(100, &[30, 70]);
state.handle(Input::Char('['));
assert_eq!(state.display_rows[state.top], DisplayRow::Line(5));
state.top = state
.display_rows
.iter()
.position(|&row| row == (DisplayRow::Fold { start: 41, end: 60 }))
.unwrap();
state.handle(Input::Char('['));
assert_eq!(state.excerpt_ranges, [(5, 41), (45, 81)]);
assert_eq!(state.display_rows[state.top], DisplayRow::Line(45));
state.handle(Input::End);
state.handle(Input::Char(']'));
assert_eq!(state.excerpt_ranges, [(5, 41), (45, 96)]);
assert_eq!(state.display_rows[state.top], DisplayRow::Line(81));
}
#[test]
fn navigation_uses_display_length_and_custom_increment() {
let mut state = excerpt_state(100, &[30]);
state.handle(Input::End);
state.handle(Input::Down);
assert_eq!(state.top, 22);
state.handle(Input::Up);
assert_eq!(state.top, 21);
state.handle(Input::Home);
state.handle(Input::Up);
assert_eq!(state.top, 0);
state.set_context_options(0, 2);
state.handle(Input::Char('['));
assert_eq!(state.excerpt_ranges, [(28, 31)]);
assert_eq!(state.display_rows[state.top], DisplayRow::Line(28));
state.handle(Input::Char(']'));
assert_eq!(state.excerpt_ranges, [(28, 33)]);
}
#[test]
fn metadata_empty_and_wholly_added_removed_files_preserve_alignment() {
let mut state = DiffState::new();
let mut comparison = comparison();
comparison.files[0].hunks.clear();
comparison.files[0].after = comparison.files[0].before.clone();
state.comparison = Some(comparison);
state.align_file();
assert!(state.excerpt_ranges.is_empty());
assert!(state.display_rows.is_empty());
assert_eq!(state.rows.len(), 5);
state.file = 1;
state.align_file();
assert_eq!(
state.display_rows,
[DisplayRow::Line(0), DisplayRow::Line(1)]
);
assert!(
state
.rows
.iter()
.all(|row| row.kind == RowKind::Added && row.before.is_none())
);
let file = &mut state.comparison.as_mut().unwrap().files[1];
std::mem::swap(&mut file.before, &mut file.after);
file.hunks[0].before_len = 2;
file.hunks[0].after_len = 0;
state.align_file();
assert_eq!(
state.display_rows,
[DisplayRow::Line(0), DisplayRow::Line(1)]
);
assert!(
state
.rows
.iter()
.all(|row| row.kind == RowKind::Removed && row.after.is_none())
);
state.file = 2;
state.align_file();
assert!(state.rows.is_empty());
assert!(state.display_rows.is_empty());
assert!(state.comparison.as_ref().unwrap().files[2].notice.is_some());
}
fn revision(
target: RevisionTarget,
label: &str,