feat: add expandable diff excerpts with configurable context
This commit is contained in:
+340
-2
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user