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,
+12 -1
View File
@@ -1064,6 +1064,7 @@ pub struct Editor {
file_explorer_view: FileView,
count: Option<usize>,
diff: Option<DiffState>,
diff_context_options: (usize, usize),
next_diff_request: u64,
outstanding_diff_request: Option<u64>,
}
@@ -1134,6 +1135,7 @@ impl Editor {
file_explorer_view: FileView::Tree,
count: None,
diff: None,
diff_context_options: (10, 15),
next_diff_request: 0,
outstanding_diff_request: None,
};
@@ -1240,6 +1242,13 @@ impl Editor {
self.diff.as_ref()
}
pub(crate) fn set_diff_context_options(&mut self, context_lines: usize, expand_lines: usize) {
self.diff_context_options = (context_lines, expand_lines);
if let Some(diff) = &mut self.diff {
diff.set_context_options(context_lines, expand_lines);
}
}
pub(crate) fn take_diff_request(&mut self) -> Option<(u64, Operation)> {
let operation = self.diff.as_mut()?.take_request()?;
self.next_diff_request = self
@@ -1275,7 +1284,9 @@ impl Editor {
self.active.mode = Mode::Normal;
}
self.outstanding_diff_request = None;
self.diff = Some(DiffState::new());
let mut diff = DiffState::new();
diff.set_context_options(self.diff_context_options.0, self.diff_context_options.1);
self.diff = Some(diff);
}
fn handle_diff(&mut self, input: Input) {
+101 -4
View File
@@ -34,6 +34,8 @@ pub struct Config {
pub file_picker_view: String,
pub file_explorer_view: String,
pub clipboard_mode: String,
pub diff_context_lines: usize,
pub diff_expand_lines: usize,
}
#[derive(Debug, Deserialize)]
@@ -68,6 +70,8 @@ impl Default for Config {
file_picker_view: "tree".into(),
file_explorer_view: "tree".into(),
clipboard_mode: "system".into(),
diff_context_lines: 10,
diff_expand_lines: 15,
}
}
}
@@ -88,6 +92,8 @@ pub fn load_config(path: &Path) -> Result<Config, String> {
file_picker_view,
file_explorer_view,
clipboard_mode,
diff_context_lines,
diff_expand_lines,
..
} => {
let mut config = Config::default();
@@ -110,6 +116,8 @@ pub fn load_config(path: &Path) -> Result<Config, String> {
config.file_picker_view = file_picker_view;
config.file_explorer_view = file_explorer_view;
config.clipboard_mode = clipboard_mode;
config.diff_context_lines = diff_context_lines;
config.diff_expand_lines = diff_expand_lines;
Ok(config)
}
Response::Error {
@@ -372,6 +380,34 @@ struct DocumentRequest<'a> {
event: &'a DocumentEvent,
}
fn diff_line_count<'de, D: serde::Deserializer<'de>>(
deserializer: D,
label: &str,
minimum: usize,
) -> Result<usize, D::Error> {
let value = serde_json::Value::deserialize(deserializer)?;
value
.as_u64()
.and_then(|value| usize::try_from(value).ok())
.filter(|value| *value >= minimum)
.ok_or_else(|| {
serde::de::Error::custom(format!(
"{label} must be an Integer between {minimum} and {}",
usize::MAX
))
})
}
fn diff_context_lines<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<usize, D::Error> {
diff_line_count(deserializer, "diff_context_lines", 0)
}
fn diff_expand_lines<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<usize, D::Error> {
diff_line_count(deserializer, "diff_expand_lines", 1)
}
#[derive(Deserialize)]
#[serde(tag = "type", deny_unknown_fields)]
enum Response {
@@ -397,6 +433,10 @@ enum Response {
file_picker_view: String,
file_explorer_view: String,
clipboard_mode: String,
#[serde(deserialize_with = "diff_context_lines")]
diff_context_lines: usize,
#[serde(deserialize_with = "diff_expand_lines")]
diff_expand_lines: usize,
},
#[serde(rename = "result")]
Result {
@@ -1048,14 +1088,71 @@ end
assert!(load_config(&path).unwrap_err().contains(error), "{value}");
}
for frame in [
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system"}"#,
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"leader":null,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system"}"#,
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"leader":7,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system"}"#,
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","diff_context_lines":10,"diff_expand_lines":15}"#,
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"leader":null,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","diff_context_lines":10,"diff_expand_lines":15}"#,
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"leader":7,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","diff_context_lines":10,"diff_expand_lines":15}"#,
] {
assert!(serde_json::from_str::<Response>(frame).is_err(), "{frame}");
}
}
#[test]
fn config_diff_excerpt_counts_accept_custom_values_and_reject_invalid_values() {
let fixture = Fixture::new();
for (context, expand) in [(0, 7), (3, 1), (usize::MAX, usize::MAX)] {
let path = fixture.file(
"config.rb",
&format!(
"Vamp.configure {{ |config| config.diff_context_lines = {context}; config.diff_expand_lines = {expand} }}"
),
);
let loaded = load_config(&path).unwrap();
assert_eq!(
(loaded.diff_context_lines, loaded.diff_expand_lines),
(context, expand)
);
}
let frame = serde_json::json!({
"version": 1, "type": "config", "plugins": [], "lsp": {},
"bindings": [], "leader": "space", "file_explorer_side": "right",
"file_picker_view": "tree", "file_explorer_view": "tree",
"clipboard_mode": "system", "diff_context_lines": 0, "diff_expand_lines": 7
});
for setting in ["diff_context_lines", "diff_expand_lines"] {
for value in [
"-1",
"1.5",
"\"3\"",
"true",
"false",
"nil",
"18446744073709551616",
"0",
] {
if setting == "diff_context_lines" && value == "0" {
continue;
}
let path = fixture.file(
"config.rb",
&format!("Vamp.configure {{ |config| config.{setting} = {value} }}"),
);
let error = load_config(&path).unwrap_err();
assert!(
error.contains(setting) && error.contains("must be"),
"{value}: {error}"
);
let mut invalid_frame = frame.clone();
invalid_frame[setting] =
serde_json::from_str(if value == "nil" { "null" } else { value }).unwrap();
let error = serde_json::from_value::<Response>(invalid_frame)
.err()
.unwrap();
assert!(error.to_string().contains(setting), "{value}: {error}");
}
}
}
#[test]
fn lsp_configuration_is_disabled_unless_explicitly_enabled() {
let fixture = Fixture::new();
@@ -1431,7 +1528,7 @@ end"#,
"version": 1, "type": "config", "plugins": [], "lsp": {},
"bindings": [], "leader": "space", "file_explorer_side": "right",
"file_picker_view": "tree", "file_explorer_view": "tree",
"clipboard_mode": "system"
"clipboard_mode": "system", "diff_context_lines": 10, "diff_expand_lines": 15
});
let Response::Config { lsp, .. } =
serde_json::from_value::<Response>(frame.clone()).unwrap()
+30 -1
View File
@@ -16,6 +16,8 @@ module Vamp
@file_picker_view = "tree"
@file_explorer_view = "tree"
@clipboard_mode = "system"
@diff_context_lines = 10
@diff_expand_lines = 15
end
# @param path [String] relative to the configuration file
@@ -60,6 +62,18 @@ module Vamp
@clipboard_mode = nonempty_string!(mode, "clipboard mode")
end
# @param lines [Integer] nonnegative context lines around each diff change
# @return [Integer]
def diff_context_lines=(lines)
@diff_context_lines = diff_line_count!(lines, "diff_context_lines", 0)
end
# @param lines [Integer] positive lines revealed per diff expansion
# @return [Integer]
def diff_expand_lines=(lines)
@diff_expand_lines = diff_line_count!(lines, "diff_expand_lines", 1)
end
# @return [Module<Vamp::Core>] frozen namespace with catalog-generated Action methods
def core
Core
@@ -90,11 +104,26 @@ module Vamp
def response
{ "version" => 1, "type" => "config", "plugins" => @plugins, "lsp" => lsp_options, "bindings" => @bindings,
"leader" => @leader, "file_explorer_side" => @file_explorer_side, "file_picker_view" => @file_picker_view,
"file_explorer_view" => @file_explorer_view, "clipboard_mode" => @clipboard_mode }
"file_explorer_view" => @file_explorer_view, "clipboard_mode" => @clipboard_mode,
"diff_context_lines" => @diff_context_lines, "diff_expand_lines" => @diff_expand_lines }
end
private
# @param value [Integer]
# @param label [String]
# @param minimum [Integer]
# @return [Integer]
def diff_line_count!(value, label, minimum)
raise TypeError, "#{label} must be an Integer" unless value.is_a?(Integer)
maximum = (1 << 64) - 1
unless value.between?(minimum, maximum)
raise ArgumentError, "#{label} must be between #{minimum} and #{maximum}"
end
value
end
# @param value [String]
# @param label [String]
# @return [String]
+1
View File
@@ -202,6 +202,7 @@ impl EditorSession {
config.clipboard_mode
));
}
editor.set_diff_context_options(config.diff_context_lines, config.diff_expand_lines);
let mut plugins = BTreeMap::new();
for configured in config.plugins {
let path = configured.path;
+95 -12
View File
@@ -32,7 +32,7 @@ use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
use crate::{
diff::{ChangedFile, DiffState, FileStatus, Focus, Phase, RowKind},
diff::{ChangedFile, DiffState, DisplayRow, FileStatus, Focus, Phase, RowKind},
editor::{
Completion, Cursor, Editor, Hover, Input, Mode, Palette, Selection, StartupDiagnostic,
},
@@ -1206,7 +1206,7 @@ fn draw_diff(
}
frame.render_widget(
Paragraph::new(
"^E explorer · Tab focus · i filter · Enter open/toggle · ↑/↓ scroll/tree · ←/→ horizontal · Esc close",
"^E explorer · Tab focus · i filter · Enter open/toggle · ↑/↓ scroll/tree · [/] expand above/below · ←/→ horizontal · Esc close",
)
.style(Style::default().fg(Color::DarkGray)),
help,
@@ -1253,14 +1253,16 @@ fn draw_diff_pane(
);
let focus = if before { Focus::Before } else { Focus::After };
let content = diff_panel(frame, area, &title, diff.focus == focus);
if diff.rows.is_empty() {
if diff.display_rows.is_empty() {
frame.render_widget(
Paragraph::new(
file.notice
.as_deref()
.map(display_text)
.unwrap_or_else(|| "Empty file".into()),
),
Paragraph::new(file.notice.as_deref().map(display_text).unwrap_or_else(|| {
if diff.rows.is_empty() {
"Empty file"
} else {
"No text changes"
}
.into()
})),
content,
);
return;
@@ -1278,8 +1280,8 @@ fn draw_diff_pane(
0
};
let width = usize::from(content.width).saturating_sub(gutter);
for (aligned_row, row) in diff
.rows
for (display_row, row) in diff
.display_rows
.iter()
.enumerate()
.skip(diff.top)
@@ -1287,10 +1289,25 @@ fn draw_diff_pane(
{
let row_area = Rect::new(
content.x,
content.y + (aligned_row - diff.top) as u16,
content.y + (display_row - diff.top) as u16,
content.width,
1,
);
let aligned_row = match row {
DisplayRow::Line(index) => *index,
DisplayRow::Fold { start, end } => {
frame.render_widget(
Paragraph::new(format!(
"… {} unchanged lines hidden · [/] expand context",
end - start
))
.style(Style::default().fg(Color::DarkGray)),
row_area,
);
continue;
}
};
let row = &diff.rows[aligned_row];
let index = if before { row.before } else { row.after };
let Some(index) = index else {
let style = Style::default().fg(Color::Rgb(72, 72, 72)).bg(Color::Reset);
@@ -2546,6 +2563,72 @@ mod tests {
);
}
#[test]
fn diff_excerpts_render_original_lines_and_expand_without_losing_changes() {
let before: Vec<_> = (0..80)
.map(|index| format!("let row_{index:03} = {index};"))
.collect();
let mut after = before.clone();
after[30] = "let first_change = 900;".into();
after[60] = "let second_change = 901;".into();
let mut diff = diff_comparison(vec![ChangedFile {
path: "excerpts.rs".into(),
old_path: None,
status: FileStatus::Modified,
before,
after,
hunks: [30, 60]
.map(|index| crate::diff::Hunk {
before_start: index,
before_len: 1,
after_start: index,
after_len: 1,
})
.into(),
notice: None,
}]);
diff.set_context_options(1, 3);
diff.handle(Input::Ctrl('e'));
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(160, 24)).unwrap();
let mut panels = WheelPanels::default();
render_diff(&mut terminal, &diff, &mut panels);
let text = screen_text(&terminal);
for visible in [
"30 let row_029",
"31- let row_030",
"31+ let first_change",
"61+ let second_change",
] {
assert!(text.contains(visible), "missing {visible}: {text}");
}
for hidden in ["row_028", "row_032", "row_058", "row_062"] {
assert!(!text.contains(hidden), "unexpected {hidden}: {text}");
}
let before_area = panels.diff.as_ref().unwrap().regions[1].0;
let after_area = panels.diff.as_ref().unwrap().regions[2].0;
let buffer = terminal.backend().buffer();
assert_eq!(
buffer[(before_area.x + 5, before_area.y + 3)].fg,
Color::Magenta
);
assert_eq!(
buffer[(before_area.x + 5, before_area.y + 3)].bg,
Color::Rgb(64, 24, 24)
);
assert_eq!(
buffer[(after_area.x + 5, after_area.y + 3)].bg,
Color::Rgb(24, 48, 32)
);
diff.handle(Input::Char('['));
diff.handle(Input::Home);
render_diff(&mut terminal, &diff, &mut panels);
let text = screen_text(&terminal);
assert!(text.contains("row_026"));
assert!(!text.contains("row_025"));
assert!(text.contains("first_change"));
assert!(text.contains("second_change"));
}
#[test]
fn diff_view_aligns_deletion_holes_numbers_colors_and_shared_vertical_scroll() {
let mut diff = diff_comparison(vec![deletion_file()]);