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,
|
||||
|
||||
+12
-1
@@ -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
@@ -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
@@ -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]
|
||||
|
||||
@@ -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
@@ -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()]);
|
||||
|
||||
Reference in New Issue
Block a user