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
+4
View File
@@ -16,6 +16,8 @@ Vamp.configure do |config|
config.file_explorer_side = "right" config.file_explorer_side = "right"
config.file_explorer_view = "tree" config.file_explorer_view = "tree"
config.file_picker_view = "tree" config.file_picker_view = "tree"
config.diff_context_lines = 10
config.diff_expand_lines = 15
config.bind "ctrl+h", config.core.left, mode: config.modes.tree config.bind "ctrl+h", config.core.left, mode: config.modes.tree
config.bind "ctrl+l", config.core.right, mode: config.modes.tree config.bind "ctrl+l", config.core.right, mode: config.modes.tree
config.plugin "plugins/greeting.rb" config.plugin "plugins/greeting.rb"
@@ -40,6 +42,8 @@ Plugin paths resolve relative to the config file, not the shell's working direct
`config.file_explorer_side` defaults to `"right"`; other choices are `"left"`, `"top"`, and `"bottom"`. Independent `file_explorer_view` / `file_picker_view` preferences both default to `"tree"` and accept `"list"` as an alternative. Invalid preference strings report notices and retain defaults while valid bindings still install. Non-string/empty values disable the whole configuration like other Ruby type errors. `config.file_explorer_side` defaults to `"right"`; other choices are `"left"`, `"top"`, and `"bottom"`. Independent `file_explorer_view` / `file_picker_view` preferences both default to `"tree"` and accept `"list"` as an alternative. Invalid preference strings report notices and retain defaults while valid bindings still install. Non-string/empty values disable the whole configuration like other Ruby type errors.
`config.clipboard_mode` accepts `"system"` (default) or `"separate"`. An invalid policy string reports a notice and retains System while valid bindings still install; non-string/empty values reject the whole Ruby configuration. `config.clipboard_mode` accepts `"system"` (default) or `"separate"`. An invalid policy string reports a notice and retains System while valid bindings still install; non-string/empty values reject the whole Ruby configuration.
`config.diff_context_lines = 10` sets how many unchanged aligned lines appear above and below each diff change; it defaults to `10` and accepts nonnegative integers, including `0` for changes only. `config.diff_expand_lines = 15` sets how many additional lines each directional expansion reveals per keypress; it defaults to `15` and accepts positive integers. Both are bounded by the platform's Rust `usize` maximum (`18446744073709551615` on 64-bit systems). Floats, strings, booleans, negative or oversized values, and a zero expansion step reject the whole Ruby configuration.
## Actions and modes ## Actions and modes
`config.core` exposes the built-in actions below as immutable `Action` values; `config.modes` exposes immutable `Mode` values. Misspelled methods fail during configuration evaluation, and `bind` rejects the wrong value type. Built-in action strings, built-in Ex aliases (such as `"q"` or `"w"`), and mode strings are not accepted. Only plugin-owned commands use exact string names (`"greet"`, without `:` or arguments) and can only be bound in Normal mode. Interactive Ex commands such as `:q` and `:w path` remain textual. Ruby provides runtime checks, not compile-time checking; the editor still validates action/mode compatibility. `config.core` exposes the built-in actions below as immutable `Action` values; `config.modes` exposes immutable `Mode` values. Misspelled methods fail during configuration evaluation, and `bind` rejects the wrong value type. Built-in action strings, built-in Ex aliases (such as `"q"` or `"w"`), and mode strings are not accepted. Only plugin-owned commands use exact string names (`"greet"`, without `:` or arguments) and can only be bound in Normal mode. Interactive Ex commands such as `:q` and `:w path` remain textual. Ruby provides runtime checks, not compile-time checking; the editor still validates action/mode compatibility.
+7 -1
View File
@@ -215,11 +215,17 @@ The read-only comparison has Before on the left, After on the right, and the cha
File rows show colored **A** (added, green), **M** (modified, yellow), **D** (removed, red), or **R** (renamed, cyan) markers in Tree and List views, including filtered results. Rename rows show the new name followed by `← old/path`; directories have no file-status marker. Ordinary file exploration remains unannotated. File rows show colored **A** (added, green), **M** (modified, yellow), **D** (removed, red), or **R** (renamed, cyan) markers in Tree and List views, including filtered results. Rename rows show the new name followed by `← old/path`; directories have no file-status marker. Ordinary file exploration remains unannotated.
Text panes show change excerpts with **10 unchanged lines above and below** each change, preserving original file line numbers. Overlapping or touching excerpts merge; omitted stretches show an unchanged-line count in both panes. Files with only metadata changes show their notice or “No text changes” rather than unrelated source.
With a text pane focused, **`[` expands above** and **`]` expands below** the excerpt at the top of the viewport, revealing up to **15 lines** per keypress. A hidden-line marker targets the following excerpt, or the preceding excerpt at the end of the file. Expansion clamps at file boundaries and merges excerpts when they meet. Existing visible source stays anchored; expanding above a leading/interior marker brings the newly revealed context into view. Opening a file resets its excerpts to the configured initial context.
Configure these counts in Ruby with `config.diff_context_lines = 10` (nonnegative; `0` shows changes only) and `config.diff_expand_lines = 15` (positive).
Changed text rows use muted red backgrounds in Before and muted green backgrounds in After, including blank tails and real empty lines. Foreground colors use the editor's bundled Tree-sitter syntax highlighting, parsed independently for each snapshot and its path (including the old path for renames). Unknown languages retain the normal foreground; historical snapshots do not borrow live-buffer LSP tokens. Changed text rows use muted red backgrounds in Before and muted green backgrounds in After, including blank tails and real empty lines. Foreground colors use the editor's bundled Tree-sitter syntax highlighting, parsed independently for each snapshot and its path (including the old path for renames). Unknown languages retain the normal foreground; historical snapshots do not borrow live-buffer LSP tokens.
Missing counterparts use adjacent muted `╱` diagonals (U+2571, box drawings light diagonal) on the normal terminal background, one per column. Unlike the text slash `/`, this glyph is designed to reach cell corners so diagonals can join end to end across successive rows. Pixel-perfect joins depend on the terminal's font and line-spacing rendering. Actual empty source lines retain their normal/change background and are not hatched. Missing counterparts use adjacent muted `╱` diagonals (U+2571, box drawings light diagonal) on the normal terminal background, one per column. Unlike the text slash `/`, this glyph is designed to reach cell corners so diagonals can join end to end across successive rows. Pixel-perfect joins depend on the terminal's font and line-spacing rendering. Actual empty source lines retain their normal/change background and are not hatched.
Tab/Shift+Tab cycles focus between Files, Before, and After. In text panes, Up/Down or Ctrl+j/k scrolls both panes together; Left/Right or Ctrl+h/l scrolls the focused pane horizontally. Home/End goes to the first/last tree row or aligned text row. Wheel input over a text pane scrolls both panes; over Files it navigates visible tree rows without opening them. Esc first leaves filtering when the tree is focused; otherwise it closes the diff and preserves the editor's buffers, cursors, unsaved text, and undo history. Typing and pasted text cannot edit either snapshot. Tab/Shift+Tab cycles focus between Files, Before, and After. In text panes, Up/Down or Ctrl+j/k scrolls both panes together; Left/Right or Ctrl+h/l scrolls the focused pane horizontally. Home/End goes to the first/last tree row or displayed excerpt/hidden-line row. Wheel input over a text pane scrolls both panes; over Files it navigates visible tree rows without opening them. Esc first leaves filtering when the tree is focused; otherwise it closes the diff and preserves the editor's buffers, cursors, unsaved text, and undo history. Typing and pasted text cannot edit either snapshot.
Binary data, missing final newlines, permission/type changes, symlink targets, and submodule pointers have explicit notices. Symlinks are not followed and submodule contents are not traversed. jj conflicts use jj's file-content projection and carry conflict notices; ordinary files remain readable. Identical-content moves are shown as renames; modified moves appear as an added and removed file. Binary data, missing final newlines, permission/type changes, symlink targets, and submodule pointers have explicit notices. Symlinks are not followed and submodule contents are not traversed. jj conflicts use jj's file-content projection and carry conflict notices; ordinary files remain readable. Identical-content moves are shown as renames; modified moves appear as an added and removed file.
+340 -2
View File
@@ -123,6 +123,12 @@ pub struct AlignedRow {
pub kind: RowKind, pub kind: RowKind,
} }
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum DisplayRow {
Line(usize),
Fold { start: usize, end: usize },
}
#[derive(Debug)] #[derive(Debug)]
struct SelectorPosition { struct SelectorPosition {
category: usize, category: usize,
@@ -156,6 +162,10 @@ pub struct DiffState {
pub left_before: usize, pub left_before: usize,
pub left_after: usize, pub left_after: usize,
pub rows: Vec<AlignedRow>, pub rows: Vec<AlignedRow>,
pub display_rows: Vec<DisplayRow>,
excerpt_ranges: Vec<(usize, usize)>,
context_lines: usize,
expand_lines: usize,
pending: Option<Operation>, pending: Option<Operation>,
request_kind: Option<RequestKind>, request_kind: Option<RequestKind>,
visible: Vec<usize>, visible: Vec<usize>,
@@ -187,6 +197,10 @@ impl DiffState {
left_before: 0, left_before: 0,
left_after: 0, left_after: 0,
rows: Vec::new(), rows: Vec::new(),
display_rows: Vec::new(),
excerpt_ranges: Vec::new(),
context_lines: 10,
expand_lines: 15,
pending: Some(Operation::Discover), pending: Some(Operation::Discover),
request_kind: Some(RequestKind::Catalog), request_kind: Some(RequestKind::Catalog),
visible: Vec::new(), visible: Vec::new(),
@@ -318,7 +332,7 @@ impl DiffState {
self.top = if forward { self.top = if forward {
self.top self.top
.saturating_add(1) .saturating_add(1)
.min(self.rows.len().saturating_sub(1)) .min(self.display_rows.len().saturating_sub(1))
} else { } else {
self.top.saturating_sub(1) self.top.saturating_sub(1)
}; };
@@ -540,9 +554,11 @@ impl DiffState {
self.top = if input == Input::Home { self.top = if input == Input::Home {
0 0
} else { } 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 { if let Some(files) = &mut self.files {
@@ -552,6 +568,8 @@ impl DiffState {
fn align_file(&mut self) { fn align_file(&mut self) {
self.rows.clear(); self.rows.clear();
self.display_rows.clear();
self.excerpt_ranges.clear();
self.top = 0; self.top = 0;
self.left_before = 0; self.left_before = 0;
self.left_after = 0; self.left_after = 0;
@@ -615,6 +633,125 @@ impl DiffState {
file.after.len(), file.after.len(),
false, 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 super::*;
use crate::editor::{Editor, Effect, Mode}; 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( fn revision(
target: RevisionTarget, target: RevisionTarget,
label: &str, label: &str,
+12 -1
View File
@@ -1064,6 +1064,7 @@ pub struct Editor {
file_explorer_view: FileView, file_explorer_view: FileView,
count: Option<usize>, count: Option<usize>,
diff: Option<DiffState>, diff: Option<DiffState>,
diff_context_options: (usize, usize),
next_diff_request: u64, next_diff_request: u64,
outstanding_diff_request: Option<u64>, outstanding_diff_request: Option<u64>,
} }
@@ -1134,6 +1135,7 @@ impl Editor {
file_explorer_view: FileView::Tree, file_explorer_view: FileView::Tree,
count: None, count: None,
diff: None, diff: None,
diff_context_options: (10, 15),
next_diff_request: 0, next_diff_request: 0,
outstanding_diff_request: None, outstanding_diff_request: None,
}; };
@@ -1240,6 +1242,13 @@ impl Editor {
self.diff.as_ref() 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)> { pub(crate) fn take_diff_request(&mut self) -> Option<(u64, Operation)> {
let operation = self.diff.as_mut()?.take_request()?; let operation = self.diff.as_mut()?.take_request()?;
self.next_diff_request = self self.next_diff_request = self
@@ -1275,7 +1284,9 @@ impl Editor {
self.active.mode = Mode::Normal; self.active.mode = Mode::Normal;
} }
self.outstanding_diff_request = None; 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) { 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_picker_view: String,
pub file_explorer_view: String, pub file_explorer_view: String,
pub clipboard_mode: String, pub clipboard_mode: String,
pub diff_context_lines: usize,
pub diff_expand_lines: usize,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -68,6 +70,8 @@ impl Default for Config {
file_picker_view: "tree".into(), file_picker_view: "tree".into(),
file_explorer_view: "tree".into(), file_explorer_view: "tree".into(),
clipboard_mode: "system".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_picker_view,
file_explorer_view, file_explorer_view,
clipboard_mode, clipboard_mode,
diff_context_lines,
diff_expand_lines,
.. ..
} => { } => {
let mut config = Config::default(); 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_picker_view = file_picker_view;
config.file_explorer_view = file_explorer_view; config.file_explorer_view = file_explorer_view;
config.clipboard_mode = clipboard_mode; config.clipboard_mode = clipboard_mode;
config.diff_context_lines = diff_context_lines;
config.diff_expand_lines = diff_expand_lines;
Ok(config) Ok(config)
} }
Response::Error { Response::Error {
@@ -372,6 +380,34 @@ struct DocumentRequest<'a> {
event: &'a DocumentEvent, 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)] #[derive(Deserialize)]
#[serde(tag = "type", deny_unknown_fields)] #[serde(tag = "type", deny_unknown_fields)]
enum Response { enum Response {
@@ -397,6 +433,10 @@ enum Response {
file_picker_view: String, file_picker_view: String,
file_explorer_view: String, file_explorer_view: String,
clipboard_mode: 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")] #[serde(rename = "result")]
Result { Result {
@@ -1048,14 +1088,71 @@ end
assert!(load_config(&path).unwrap_err().contains(error), "{value}"); assert!(load_config(&path).unwrap_err().contains(error), "{value}");
} }
for frame in [ 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":[],"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"}"#, 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"}"#, 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}"); 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] #[test]
fn lsp_configuration_is_disabled_unless_explicitly_enabled() { fn lsp_configuration_is_disabled_unless_explicitly_enabled() {
let fixture = Fixture::new(); let fixture = Fixture::new();
@@ -1431,7 +1528,7 @@ end"#,
"version": 1, "type": "config", "plugins": [], "lsp": {}, "version": 1, "type": "config", "plugins": [], "lsp": {},
"bindings": [], "leader": "space", "file_explorer_side": "right", "bindings": [], "leader": "space", "file_explorer_side": "right",
"file_picker_view": "tree", "file_explorer_view": "tree", "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, .. } = let Response::Config { lsp, .. } =
serde_json::from_value::<Response>(frame.clone()).unwrap() serde_json::from_value::<Response>(frame.clone()).unwrap()
+30 -1
View File
@@ -16,6 +16,8 @@ module Vamp
@file_picker_view = "tree" @file_picker_view = "tree"
@file_explorer_view = "tree" @file_explorer_view = "tree"
@clipboard_mode = "system" @clipboard_mode = "system"
@diff_context_lines = 10
@diff_expand_lines = 15
end end
# @param path [String] relative to the configuration file # @param path [String] relative to the configuration file
@@ -60,6 +62,18 @@ module Vamp
@clipboard_mode = nonempty_string!(mode, "clipboard mode") @clipboard_mode = nonempty_string!(mode, "clipboard mode")
end 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 # @return [Module<Vamp::Core>] frozen namespace with catalog-generated Action methods
def core def core
Core Core
@@ -90,11 +104,26 @@ module Vamp
def response def response
{ "version" => 1, "type" => "config", "plugins" => @plugins, "lsp" => lsp_options, "bindings" => @bindings, { "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, "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 end
private 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 value [String]
# @param label [String] # @param label [String]
# @return [String] # @return [String]
+1
View File
@@ -202,6 +202,7 @@ impl EditorSession {
config.clipboard_mode config.clipboard_mode
)); ));
} }
editor.set_diff_context_options(config.diff_context_lines, config.diff_expand_lines);
let mut plugins = BTreeMap::new(); let mut plugins = BTreeMap::new();
for configured in config.plugins { for configured in config.plugins {
let path = configured.path; let path = configured.path;
+95 -12
View File
@@ -32,7 +32,7 @@ use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr; use unicode_width::UnicodeWidthStr;
use crate::{ use crate::{
diff::{ChangedFile, DiffState, FileStatus, Focus, Phase, RowKind}, diff::{ChangedFile, DiffState, DisplayRow, FileStatus, Focus, Phase, RowKind},
editor::{ editor::{
Completion, Cursor, Editor, Hover, Input, Mode, Palette, Selection, StartupDiagnostic, Completion, Cursor, Editor, Hover, Input, Mode, Palette, Selection, StartupDiagnostic,
}, },
@@ -1206,7 +1206,7 @@ fn draw_diff(
} }
frame.render_widget( frame.render_widget(
Paragraph::new( 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)), .style(Style::default().fg(Color::DarkGray)),
help, help,
@@ -1253,14 +1253,16 @@ fn draw_diff_pane(
); );
let focus = if before { Focus::Before } else { Focus::After }; let focus = if before { Focus::Before } else { Focus::After };
let content = diff_panel(frame, area, &title, diff.focus == focus); let content = diff_panel(frame, area, &title, diff.focus == focus);
if diff.rows.is_empty() { if diff.display_rows.is_empty() {
frame.render_widget( frame.render_widget(
Paragraph::new( Paragraph::new(file.notice.as_deref().map(display_text).unwrap_or_else(|| {
file.notice if diff.rows.is_empty() {
.as_deref() "Empty file"
.map(display_text) } else {
.unwrap_or_else(|| "Empty file".into()), "No text changes"
), }
.into()
})),
content, content,
); );
return; return;
@@ -1278,8 +1280,8 @@ fn draw_diff_pane(
0 0
}; };
let width = usize::from(content.width).saturating_sub(gutter); let width = usize::from(content.width).saturating_sub(gutter);
for (aligned_row, row) in diff for (display_row, row) in diff
.rows .display_rows
.iter() .iter()
.enumerate() .enumerate()
.skip(diff.top) .skip(diff.top)
@@ -1287,10 +1289,25 @@ fn draw_diff_pane(
{ {
let row_area = Rect::new( let row_area = Rect::new(
content.x, content.x,
content.y + (aligned_row - diff.top) as u16, content.y + (display_row - diff.top) as u16,
content.width, content.width,
1, 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 index = if before { row.before } else { row.after };
let Some(index) = index else { let Some(index) = index else {
let style = Style::default().fg(Color::Rgb(72, 72, 72)).bg(Color::Reset); 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] #[test]
fn diff_view_aligns_deletion_holes_numbers_colors_and_shared_vertical_scroll() { fn diff_view_aligns_deletion_holes_numbers_colors_and_shared_vertical_scroll() {
let mut diff = diff_comparison(vec![deletion_file()]); let mut diff = diff_comparison(vec![deletion_file()]);