feat: add configurable diff modes and split orientation

This commit is contained in:
2026-10-12 23:43:15 +02:00
parent c35abe0f03
commit d1855dbdb7
14 changed files with 847 additions and 169 deletions
+185 -28
View File
@@ -135,6 +135,13 @@ pub enum Focus {
After,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ViewMode {
Split,
Unified,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum PaneLayout {
Split,
@@ -179,6 +186,8 @@ pub struct DiffState {
pub catalog: Option<Catalog>,
pub phase: Phase,
pub word_wrap: bool,
pub view_mode: ViewMode,
pub split_axis: crate::panes::SplitAxis,
pub before: Option<Revision>,
pub after: Option<Revision>,
pub category: usize,
@@ -198,6 +207,7 @@ pub struct DiffState {
pub left_after: usize,
pub rows: Vec<AlignedRow>,
pub display_rows: Vec<DisplayRow>,
display_sides: Vec<Option<Focus>>,
excerpt_ranges: Vec<(usize, usize)>,
pub live_buffer: Option<usize>,
pub live_revision: Option<u64>,
@@ -220,6 +230,8 @@ impl DiffState {
catalog: None,
phase: Phase::Before,
word_wrap: false,
view_mode: ViewMode::Split,
split_axis: crate::panes::SplitAxis::Vertical,
before: None,
after: None,
category: 0,
@@ -239,6 +251,7 @@ impl DiffState {
left_after: 0,
rows: Vec::new(),
display_rows: Vec::new(),
display_sides: Vec::new(),
excerpt_ranges: Vec::new(),
live_buffer: None,
live_revision: None,
@@ -902,9 +915,40 @@ impl DiffState {
}
pub(crate) fn source_display_row(&self, focus: Focus, source_row: usize) -> Option<usize> {
self.display_rows.iter().position(|display| {
matches!(*display, DisplayRow::Line(index) if self.source_row(focus, index) == Some(source_row))
})
(0..self.display_rows.len())
.position(|display| self.display_source_row(focus, display) == Some(source_row))
}
pub(crate) fn display_focus(&self, display: usize) -> Option<Focus> {
self.display_sides.get(display).copied().flatten()
}
pub(crate) fn display_source_row(&self, focus: Focus, display: usize) -> Option<usize> {
if self
.display_focus(display)
.is_some_and(|side| side != focus)
{
return None;
}
match *self.display_rows.get(display)? {
DisplayRow::Line(index) => self.source_row(focus, index),
DisplayRow::Fold { .. } => None,
}
}
pub(crate) fn set_view_mode(&mut self, view_mode: ViewMode) {
if self.view_mode == view_mode {
return;
}
let anchor = self.display_rows.get(self.top).copied();
let side = self.display_focus(self.top).or_else(|| {
[self.focus, Focus::After, Focus::Before]
.into_iter()
.find(|&focus| self.display_source_row(focus, self.top).is_some())
});
self.view_mode = view_mode;
self.rebuild_display_rows();
self.restore_top(anchor, side);
}
pub fn after_display_row(&self, source_row: usize) -> Option<usize> {
@@ -951,9 +995,10 @@ impl DiffState {
})?;
self.display_rows
.iter()
.filter_map(|display| match *display {
.enumerate()
.filter_map(|(display, row)| match *row {
DisplayRow::Line(index) => self
.source_row(to, index)
.display_source_row(to, display)
.map(|row| (index.abs_diff(aligned), row)),
DisplayRow::Fold { .. } => None,
})
@@ -975,7 +1020,7 @@ impl DiffState {
previous
}
fn restore_top(&mut self, anchor: Option<DisplayRow>) {
fn restore_top(&mut self, anchor: Option<DisplayRow>, side: Option<Focus>) {
if let Some(anchor) = anchor {
let index = match anchor {
DisplayRow::Line(index) => index,
@@ -984,8 +1029,15 @@ impl DiffState {
self.top = self
.display_rows
.iter()
.position(|row| match *row {
DisplayRow::Line(row) => row == index,
.enumerate()
.position(|(display, row)| match *row {
DisplayRow::Line(row) => {
row == index
&& side.is_none_or(|side| {
self.display_focus(display)
.is_none_or(|focus| focus == side)
})
}
DisplayRow::Fold { start, end } => start <= index && index < end,
})
.unwrap_or(0);
@@ -1044,20 +1096,19 @@ impl DiffState {
after_visible.extend(row.after.map(map_after));
}
}
let top_before = self
let top_fold = self
.display_rows
.get(self.top)
.and_then(|display| match *display {
DisplayRow::Line(index) => self.rows[index].before,
DisplayRow::Fold { start, .. } => self.rows[start].before,
DisplayRow::Fold { start, .. } => Some(start),
DisplayRow::Line(_) => None,
});
let top_before = self
.display_source_row(Focus::Before, self.top)
.or_else(|| top_fold.and_then(|index| self.rows[index].before));
let top_after = self
.display_rows
.get(self.top)
.and_then(|display| match *display {
DisplayRow::Line(index) => self.rows[index].after,
DisplayRow::Fold { start, .. } => self.rows[start].after,
})
.display_source_row(Focus::After, self.top)
.or_else(|| top_fold.and_then(|index| self.rows[index].after))
.map(map_after);
let was_changed = file_is_changed(file);
let file = &mut self.comparison.as_mut().unwrap().files[self.file];
@@ -1102,7 +1153,7 @@ impl DiffState {
} else if let Some(source) = top_before
&& let Some(index) = self.rows.iter().position(|row| row.before == Some(source))
{
self.restore_top(Some(DisplayRow::Line(index)));
self.restore_top(Some(DisplayRow::Line(index)), Some(Focus::Before));
}
if was_changed != changed {
self.refresh_files();
@@ -1134,6 +1185,7 @@ impl DiffState {
self.normalize_focus();
self.rows.clear();
self.display_rows.clear();
self.display_sides.clear();
self.excerpt_ranges.clear();
if reset {
self.top = 0;
@@ -1263,6 +1315,7 @@ impl DiffState {
}
self.excerpt_ranges.truncate(count);
self.display_rows.clear();
self.display_sides.clear();
if self.excerpt_ranges.is_empty() {
return;
}
@@ -1273,9 +1326,34 @@ impl DiffState {
start: end,
end: start,
});
if self.view_mode == ViewMode::Unified {
self.display_sides.push(None);
}
}
let mut index = start;
while index < next_end {
if self.view_mode == ViewMode::Split || self.rows[index].kind == RowKind::Equal {
self.display_rows.push(DisplayRow::Line(index));
if self.view_mode == ViewMode::Unified {
self.display_sides.push(None);
}
index += 1;
continue;
}
let run_start = index;
while index < next_end && self.rows[index].kind != RowKind::Equal {
index += 1;
}
// Unified hunks show all removals before all additions, without alignment holes.
for side in [Focus::Before, Focus::After] {
for aligned in run_start..index {
if self.source_row(side, aligned).is_some() {
self.display_rows.push(DisplayRow::Line(aligned));
self.display_sides.push(Some(side));
}
}
}
}
self.display_rows
.extend((start..next_end).map(DisplayRow::Line));
end = next_end;
}
if end < self.rows.len() {
@@ -1283,6 +1361,9 @@ impl DiffState {
start: end,
end: self.rows.len(),
});
if self.view_mode == ViewMode::Unified {
self.display_sides.push(None);
}
}
}
@@ -1304,6 +1385,7 @@ impl DiffState {
let Some(top) = self.display_rows.get(self.top).copied() else {
return;
};
let top_side = self.display_focus(self.top);
let index = match target {
DisplayRow::Line(row) => self
.excerpt_ranges
@@ -1337,14 +1419,7 @@ impl DiffState {
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);
self.restore_top(Some(DisplayRow::Line(anchor)), top_side);
}
}
@@ -2804,6 +2879,88 @@ mod tests {
state
}
#[test]
fn unified_groups_changed_runs_and_roundtrips_side_specific_source_rows() {
let mut state = live_state(
["head", "old one", "old two", "tail"]
.map(str::to_owned)
.to_vec(),
["head", "new one", "new two", "extra", "tail"]
.map(str::to_owned)
.to_vec(),
false,
);
assert_eq!(state.view_mode, ViewMode::Split);
assert_eq!(state.split_axis, crate::panes::SplitAxis::Vertical);
state.top = state.after_display_row(2).unwrap();
let excerpts = state.excerpt_ranges.clone();
state.set_view_mode(ViewMode::Unified);
let rows: Vec<_> = (0..state.display_rows.len())
.map(|display| {
(
state.display_focus(display),
state.display_source_row(Focus::Before, display),
state.display_source_row(Focus::After, display),
)
})
.collect();
assert_eq!(
rows,
[
(None, Some(0), Some(0)),
(Some(Focus::Before), Some(1), None),
(Some(Focus::Before), Some(2), None),
(Some(Focus::After), None, Some(1)),
(Some(Focus::After), None, Some(2)),
(Some(Focus::After), None, Some(3)),
(None, Some(3), Some(4)),
]
);
assert_eq!(state.top, state.after_display_row(2).unwrap());
assert_eq!(
state.corresponding_row(Focus::Before, Focus::After, 2),
Some(2)
);
assert!(state.after_editable());
for focus in [Focus::Before, Focus::After] {
for source in state.source_rows(focus) {
let display = state.source_display_row(focus, source).unwrap();
assert_eq!(state.display_source_row(focus, display), Some(source));
}
}
state.set_view_mode(ViewMode::Split);
assert_eq!(state.excerpt_ranges, excerpts);
assert_eq!(state.focus, Focus::After);
assert_eq!(state.top, state.after_display_row(2).unwrap());
assert_eq!(state.display_rows.len(), 5);
}
#[test]
fn unified_live_reflow_preserves_removed_top_and_expanded_source_excerpts() {
let base: Vec<_> = (0..40).map(|row| format!("line {row}")).collect();
let mut lines = base.clone();
lines[20] = "new line".into();
let mut state = live_state(base, lines.clone(), false);
state.set_context_options(1, 3);
state.refresh_after(&lines, 1);
state.set_view_mode(ViewMode::Unified);
state.top = state.source_display_row(Focus::Before, 20).unwrap();
state.expand_source_excerpt(Focus::After, false, 20);
assert_eq!(state.display_source_row(Focus::Before, state.top), Some(20));
assert!(state.source_display_row(Focus::After, 16).is_some());
lines.insert(0, "inserted".into());
state.refresh_after(&lines, 2);
assert_eq!(state.display_focus(state.top), Some(Focus::Before));
assert_eq!(state.display_source_row(Focus::Before, state.top), Some(20));
assert!(state.source_display_row(Focus::After, 17).is_some());
state.reveal_source_row(Focus::After, 35);
let visible: Vec<_> = state.source_rows(Focus::After).collect();
state.set_view_mode(ViewMode::Split);
state.set_view_mode(ViewMode::Unified);
assert_eq!(state.source_rows(Focus::After).collect::<Vec<_>>(), visible);
assert_eq!(state.display_source_row(Focus::After, state.top), Some(35));
}
#[test]
fn character_ranges_follow_insertions_deletions_unicode_and_live_edits() {
for (before, after, expected) in [
+185 -1
View File
@@ -1,6 +1,6 @@
use crate::chrome::{ChromeLine, PopupConfig, validate_name};
use crate::clipboard::{ClipboardMode, SystemClipboard};
use crate::diff::{DiffState, Focus, Operation, Outcome, Phase, RevisionTarget};
use crate::diff::{DiffState, Focus, Operation, Outcome, Phase, RevisionTarget, ViewMode};
use crate::files::{DockSide, FilePicker, FilePresentation, FileView};
use crate::panes::{PaneDirection, PaneRect, PaneTree, SplitAxis, directional_neighbor};
use crate::plugin::{
@@ -703,6 +703,18 @@ core_actions![
true
),
("core.diff", Action::Diff, &[Mode::Normal], true),
(
"core.diff.view.toggle",
Action::DiffViewToggle,
&[Mode::Normal],
true,
),
(
"core.diff.orientation.toggle",
Action::DiffOrientationToggle,
&[Mode::Normal],
true,
),
(
"core.word_wrap.toggle",
Action::WordWrapToggle,
@@ -1652,6 +1664,7 @@ pub struct Editor {
diff_bound: Option<(u64, usize, Focus, usize)>,
diff_return_buffer: Option<usize>,
diff_context_options: (usize, usize),
diff_display_options: (ViewMode, SplitAxis),
next_diff_request: u64,
outstanding_diff_request: Option<u64>,
pending_lsp_navigation: Option<PendingLspNavigation>,
@@ -1770,6 +1783,7 @@ impl Editor {
diff_bound: None,
diff_return_buffer: None,
diff_context_options: (10, 15),
diff_display_options: (ViewMode::Split, SplitAxis::Vertical),
next_diff_request: 0,
outstanding_diff_request: None,
pending_lsp_navigation: None,
@@ -1803,6 +1817,8 @@ impl Editor {
("normal", "leader b p", "core.buffer_previous"),
("normal", "leader b l", "core.buffers"),
("normal", "leader b d", "core.buffer_delete"),
("normal", "leader v", "core.diff.view.toggle"),
("normal", "leader s", "core.diff.orientation.toggle"),
] {
editor
.bind(mode, key, Some(target.into()))
@@ -2445,6 +2461,10 @@ impl Editor {
}
}
pub fn set_diff_display_options(&mut self, view_mode: ViewMode, split_axis: SplitAxis) {
self.diff_display_options = (view_mode, split_axis);
}
pub(crate) fn take_diff_request(&mut self) -> Option<(u64, Operation)> {
let mut operation = self
.diff
@@ -2576,6 +2596,8 @@ impl Editor {
self.outstanding_diff_request = None;
let mut diff = DiffState::new();
diff.word_wrap = self.active.view.word_wrap;
diff.view_mode = self.diff_display_options.0;
diff.split_axis = self.diff_display_options.1;
diff.set_context_options(self.diff_context_options.0, self.diff_context_options.1);
self.diff = Some(diff);
}
@@ -3122,6 +3144,8 @@ impl Editor {
| Action::FilePicker
| Action::FileExplorer
| Action::DiffClose
| Action::DiffViewToggle
| Action::DiffOrientationToggle
| Action::WordWrapToggle
| Action::Quit
| Action::ForceQuit
@@ -5881,6 +5905,8 @@ impl Editor {
| Action::BufferForceDelete
| Action::Diff
| Action::DiffClose
| Action::DiffViewToggle
| Action::DiffOrientationToggle
| Action::WordWrapToggle
| Action::LspDefinition
| Action::LspHover
@@ -6685,6 +6711,44 @@ impl Editor {
}
return None;
}
if matches!(
entry.action,
Action::DiffViewToggle | Action::DiffOrientationToggle
) {
let name = entry.ex.expect("diff display action has an Ex command");
if !args.is_empty() {
self.set_message(format!("The {name} command takes no arguments"));
return None;
}
if let Some(owner) = self.diff_owner.filter(|owner| *owner != self.pane_id) {
return self.with_pane(owner, |editor| editor.execute_action(entry, args));
}
self.count = None;
self.clear_sequence();
self.palette = None;
if self.active.view.mode == Mode::Command {
self.active.view.command_line.clear();
self.active.view.mode = Mode::Normal;
}
let Some(diff) = self.diff.as_mut().filter(|diff| diff.phase == Phase::View) else {
self.set_message("No comparison is open");
return None;
};
if entry.action == Action::DiffViewToggle {
diff.set_view_mode(match diff.view_mode {
ViewMode::Split => ViewMode::Unified,
ViewMode::Unified => ViewMode::Split,
});
} else if diff.view_mode == ViewMode::Split {
diff.split_axis = match diff.split_axis {
SplitAxis::Vertical => SplitAxis::Horizontal,
SplitAxis::Horizontal => SplitAxis::Vertical,
};
} else {
self.set_message("Diff orientation applies only to split view");
}
return None;
}
if entry.action == Action::WordWrapToggle {
if !args.is_empty() {
self.set_message("The word_wrap.toggle command takes no arguments");
@@ -12844,6 +12908,126 @@ mod tests {
assert_eq!(editor.lines(), ["beta"]);
}
#[test]
fn diff_display_defaults_tabs_and_remaps_preserve_native_snapshot_carets() {
let directory = Directory::new();
let mut editor = editor();
editor.execute("diff.view.toggle");
assert_eq!(editor.message(), "No comparison is open");
editor.set_diff_display_options(ViewMode::Unified, SplitAxis::Horizontal);
editor.execute("diff");
type_text(&mut editor, " v");
assert_eq!(editor.diff().unwrap().query, " v");
assert_eq!(editor.diff().unwrap().view_mode, ViewMode::Unified);
historical_diff(
&mut editor,
&directory.0,
vec!["old one".into(), "old two".into()],
vec!["new one".into(), "new two".into()],
);
let first_tab = editor.tab_id();
let buffer = editor.buffer_id();
editor.handle(Input::Char('j'));
let caret = editor.cursor();
let text = editor.lines().to_vec();
assert!(!editor.diff_editing());
assert_eq!(editor.diff().unwrap().view_mode, ViewMode::Unified);
editor.execute("diff.orientation.toggle");
assert_eq!(editor.diff().unwrap().split_axis, SplitAxis::Horizontal);
editor
.bind("normal", "Q", Some("core.diff.view.toggle".into()))
.unwrap();
assert!(
editor
.bind("insert", "Q", Some("core.diff.view.toggle".into()))
.is_err()
);
editor.handle(Input::Char('Q'));
assert_eq!(editor.diff().unwrap().view_mode, ViewMode::Split);
editor
.bind("normal", "W", Some("core.diff.orientation.toggle".into()))
.unwrap();
editor.handle(Input::Char('W'));
assert_eq!(editor.diff().unwrap().split_axis, SplitAxis::Vertical);
type_text(&mut editor, " v");
assert_eq!(editor.diff().unwrap().view_mode, ViewMode::Unified);
assert_eq!(editor.buffer_id(), buffer);
assert_eq!(editor.cursor(), caret);
assert_eq!(editor.lines(), text);
editor.set_diff_display_options(ViewMode::Split, SplitAxis::Horizontal);
assert_eq!(editor.diff().unwrap().view_mode, ViewMode::Unified);
editor.handle(Input::Ctrl('p'));
type_text(&mut editor, "diff.view.toggle");
assert!(
editor
.palette()
.unwrap()
.entries
.iter()
.any(|entry| entry.name == "core.diff.view.toggle" && entry.enabled)
);
editor.handle(Input::Enter);
assert_eq!(editor.diff().unwrap().view_mode, ViewMode::Split);
editor.execute("tab.new");
historical_diff(
&mut editor,
&directory.0,
vec!["old".into()],
vec!["new".into()],
);
assert_eq!(editor.diff().unwrap().view_mode, ViewMode::Split);
assert_eq!(editor.diff().unwrap().split_axis, SplitAxis::Horizontal);
editor.execute("tab.previous");
assert_eq!(editor.tab_id(), first_tab);
assert_eq!(editor.buffer_id(), buffer);
assert_eq!(editor.cursor(), caret);
assert_eq!(editor.diff().unwrap().split_axis, SplitAxis::Vertical);
}
#[test]
fn diff_display_actions_keep_working_edits_files_and_filter_input_native() {
let directory = Directory::new();
let first = directory.path("first.txt");
fs::write(&first, "base").unwrap();
fs::write(directory.path("second.txt"), "second").unwrap();
let mut editor = Editor::open(Some(first), vec![]).unwrap();
let working = editor.buffer_id();
working_diff(&mut editor, &directory.0, &["first.txt", "second.txt"]);
assert_eq!(editor.diff().unwrap().focus, Focus::Files);
type_text(&mut editor, " s");
assert_eq!(editor.diff().unwrap().split_axis, SplitAxis::Horizontal);
type_text(&mut editor, " v");
assert_eq!(editor.diff().unwrap().view_mode, ViewMode::Unified);
editor.handle(Input::Char('i'));
type_text(&mut editor, " v");
assert_eq!(editor.diff().unwrap().view_mode, ViewMode::Unified);
assert_eq!(editor.file_picker().unwrap().query, " v");
editor.handle(Input::Escape);
editor.handle(Input::BackTab);
assert!(editor.diff_editing());
assert_eq!(editor.buffer_id(), working);
type_text(&mut editor, "i dv");
assert_eq!(editor.lines(), [" dvbase"]);
editor.handle(Input::Escape);
assert_eq!(editor.diff().unwrap().view_mode, ViewMode::Unified);
assert!(editor.diff().unwrap().after_display_row(0).is_some());
let caret = editor.cursor();
type_text(&mut editor, " v");
assert_eq!(editor.diff().unwrap().view_mode, ViewMode::Split);
assert_eq!(editor.cursor(), caret);
assert!(editor.diff_editing());
assert!(editor.diff.as_mut().unwrap().select_file_index(1));
editor.bind_diff_buffer();
assert_eq!(editor.diff().unwrap().file, 1);
assert_eq!(editor.diff().unwrap().split_axis, SplitAxis::Horizontal);
assert_eq!(editor.diff().unwrap().view_mode, ViewMode::Split);
assert_eq!(editor.diff().unwrap().focus, Focus::After);
type_text(&mut editor, " v");
assert_eq!(editor.diff().unwrap().view_mode, ViewMode::Unified);
assert!(editor.diff_editing());
assert_eq!(editor.lines(), ["second"]);
}
#[test]
fn word_wrap_toggle_retains_buffers_and_readonly_diff_state() {
let directory = Directory::new();
+2
View File
@@ -69,6 +69,8 @@ Diff: :diff (Normal); choose Before then After; Working checkout After edits liv
Switching Before/After preserves the aligned cursor row and display column
Selector queries own printable keys, including :; text navigation skips omitted lines
Normal [/] explicitly expands context; Insert brackets remain literal text
Normal Space v cycles split/unified; Space s flips left/right vs above/below split
Defaults: config.diff_view_mode = \"split\"; config.diff_split_orientation = \"left_right\"
Esc closes Before/After selectors; :diff.close exits an opened comparison explicitly
Remote refs/history load into temporary comparison repos without changing active refs/checkout
Changed files: normal picker/explorer bindings, Ctrl+w close; A/M/D/R status and rename paths
+3 -1
View File
@@ -1,10 +1,12 @@
//! Frontend-independent binary pane layout and spatial focus navigation.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize)]
pub enum SplitAxis {
/// Above/below.
#[serde(rename = "above_below")]
Horizontal,
/// Left/right.
#[serde(rename = "left_right")]
Vertical,
}
+75 -61
View File
@@ -44,6 +44,8 @@ pub struct Config {
pub labels: BTreeMap<String, String>,
pub diff_context_lines: usize,
pub diff_expand_lines: usize,
pub diff_view_mode: crate::diff::ViewMode,
pub diff_split_orientation: crate::panes::SplitAxis,
}
#[derive(Debug, Deserialize)]
@@ -84,6 +86,8 @@ impl Default for Config {
labels: BTreeMap::new(),
diff_context_lines: 10,
diff_expand_lines: 15,
diff_view_mode: crate::diff::ViewMode::Split,
diff_split_orientation: crate::panes::SplitAxis::Vertical,
}
}
}
@@ -95,23 +99,26 @@ pub fn load_config(path: &Path) -> Result<Config, String> {
let mut host = Host::spawn("config", &path, &serde_json::json!({}))?;
let response = host.receive(deadline)?;
match response {
Response::Config {
plugins,
lsp,
bindings,
leader,
file_explorer_side,
file_picker_view,
file_explorer_view,
clipboard_mode,
top_bar,
bottom_bar,
popup,
labels,
diff_context_lines,
diff_expand_lines,
..
} => {
Response::Config(response) => {
let ConfigFrame {
plugins,
lsp,
bindings,
leader,
file_explorer_side,
file_picker_view,
file_explorer_view,
clipboard_mode,
top_bar,
bottom_bar,
popup,
labels,
diff_context_lines,
diff_expand_lines,
diff_view_mode,
diff_split_orientation,
..
} = *response;
let mut config = Config::default();
let directory = path.parent().expect("absolute file has a parent");
for plugin in plugins {
@@ -138,6 +145,8 @@ pub fn load_config(path: &Path) -> Result<Config, String> {
config.labels = labels;
config.diff_context_lines = diff_context_lines;
config.diff_expand_lines = diff_expand_lines;
config.diff_view_mode = diff_view_mode;
config.diff_split_orientation = diff_split_orientation;
Ok(config)
}
Response::Error {
@@ -429,6 +438,36 @@ fn diff_expand_lines<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Resul
diff_line_count(deserializer, "diff_expand_lines", 1)
}
// Startup-only configuration does not inflate every queued plugin response.
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct ConfigFrame {
version: u8,
plugins: Vec<PluginConfig>,
// Keep this wire field required; resolve its configuration after decoding.
#[serde(deserialize_with = "serde_json::Value::deserialize")]
lsp: serde_json::Value,
bindings: Vec<Binding>,
leader: String,
file_explorer_side: String,
file_picker_view: String,
file_explorer_view: String,
clipboard_mode: String,
#[serde(deserialize_with = "crate::chrome::top")]
top_bar: ChromeLine,
#[serde(deserialize_with = "crate::chrome::bottom")]
bottom_bar: ChromeLine,
popup: PopupConfig,
#[serde(deserialize_with = "crate::chrome::labels")]
labels: BTreeMap<String, String>,
#[serde(deserialize_with = "diff_context_lines")]
diff_context_lines: usize,
#[serde(deserialize_with = "diff_expand_lines")]
diff_expand_lines: usize,
diff_view_mode: crate::diff::ViewMode,
diff_split_orientation: crate::panes::SplitAxis,
}
#[derive(Deserialize)]
#[serde(tag = "type", deny_unknown_fields)]
enum Response {
@@ -442,30 +481,7 @@ enum Response {
asynchronous: bool,
},
#[serde(rename = "config")]
Config {
version: u8,
plugins: Vec<PluginConfig>,
// Keep this wire field required; resolve its configuration after decoding.
#[serde(deserialize_with = "serde_json::Value::deserialize")]
lsp: serde_json::Value,
bindings: Vec<Binding>,
leader: String,
file_explorer_side: String,
file_picker_view: String,
file_explorer_view: String,
clipboard_mode: String,
#[serde(deserialize_with = "crate::chrome::top")]
top_bar: ChromeLine,
#[serde(deserialize_with = "crate::chrome::bottom")]
bottom_bar: ChromeLine,
popup: PopupConfig,
#[serde(deserialize_with = "crate::chrome::labels")]
labels: BTreeMap<String, String>,
#[serde(deserialize_with = "diff_context_lines")]
diff_context_lines: usize,
#[serde(deserialize_with = "diff_expand_lines")]
diff_expand_lines: usize,
},
Config(Box<ConfigFrame>),
#[serde(rename = "result")]
Result {
version: u8,
@@ -497,10 +513,10 @@ impl Response {
fn version(&self) -> u8 {
match self {
Self::Manifest { version, .. }
| Self::Config { version, .. }
| Self::Result { version, .. }
| Self::Update { version, .. }
| Self::Error { version, .. } => *version,
Self::Config(config) => config.version,
}
}
}
@@ -1188,9 +1204,9 @@ 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","top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"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","top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"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","top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15}"#,
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"left_right"}"#,
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","top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"left_right"}"#,
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","top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"left_right"}"#,
] {
assert!(serde_json::from_str::<Response>(frame).is_err(), "{frame}");
}
@@ -1408,7 +1424,8 @@ end
"file_picker_view": "tree", "file_explorer_view": "tree",
"top_bar": {}, "bottom_bar": {}, "labels": {},
"popup": {"command": {}, "search": {}},
"clipboard_mode": "system", "diff_context_lines": 0, "diff_expand_lines": 7
"clipboard_mode": "system", "diff_context_lines": 0, "diff_expand_lines": 7,
"diff_view_mode": "split", "diff_split_orientation": "left_right"
});
for setting in ["diff_context_lines", "diff_expand_lines"] {
for value in [
@@ -1822,22 +1839,21 @@ end"#,
"file_picker_view": "tree", "file_explorer_view": "tree",
"top_bar": {}, "bottom_bar": {}, "labels": {},
"popup": {"command": {}, "search": {}},
"clipboard_mode": "system", "diff_context_lines": 10, "diff_expand_lines": 15
"clipboard_mode": "system", "diff_context_lines": 10, "diff_expand_lines": 15,
"diff_view_mode": "split", "diff_split_orientation": "left_right"
});
let Response::Config { lsp, .. } =
serde_json::from_value::<Response>(frame.clone()).unwrap()
let Response::Config(config) = serde_json::from_value::<Response>(frame.clone()).unwrap()
else {
panic!("expected config frame");
};
assert!(crate::lsp_config::resolve(lsp).unwrap().is_empty());
assert!(crate::lsp_config::resolve(config.lsp).unwrap().is_empty());
frame["lsp"] = serde_json::json!({"rust": true});
let Response::Config { lsp, .. } =
serde_json::from_value::<Response>(frame.clone()).unwrap()
let Response::Config(config) = serde_json::from_value::<Response>(frame.clone()).unwrap()
else {
panic!("expected config frame");
};
assert_eq!(
crate::lsp_config::resolve(lsp).unwrap()[0].command,
crate::lsp_config::resolve(config.lsp).unwrap()[0].command,
["rust-analyzer"]
);
for value in [
@@ -1861,12 +1877,12 @@ end"#,
serde_json::json!({"python": {"servers": [{"command": ["server"]}], "settings": {}}}),
] {
frame["lsp"] = value;
let Response::Config { lsp, .. } =
let Response::Config(config) =
serde_json::from_value::<Response>(frame.clone()).unwrap()
else {
panic!("expected config frame");
};
assert!(crate::lsp_config::resolve(lsp).is_err(), "{frame}");
assert!(crate::lsp_config::resolve(config.lsp).is_err(), "{frame}");
}
let mut languages: serde_json::Map<String, serde_json::Value> = (0..14)
.map(|index| {
@@ -1878,20 +1894,18 @@ end"#,
.collect();
languages.insert("python".into(), serde_json::json!(true));
frame["lsp"] = serde_json::Value::Object(languages);
let Response::Config { lsp, .. } =
serde_json::from_value::<Response>(frame.clone()).unwrap()
let Response::Config(config) = serde_json::from_value::<Response>(frame.clone()).unwrap()
else {
panic!("expected config frame");
};
assert_eq!(crate::lsp_config::resolve(lsp).unwrap().len(), 16);
assert_eq!(crate::lsp_config::resolve(config.lsp).unwrap().len(), 16);
frame["lsp"]["ruby"] = serde_json::json!(true);
let Response::Config { lsp, .. } =
serde_json::from_value::<Response>(frame.clone()).unwrap()
let Response::Config(config) = serde_json::from_value::<Response>(frame.clone()).unwrap()
else {
panic!("expected config frame");
};
assert_eq!(
crate::lsp_config::resolve(lsp).unwrap_err(),
crate::lsp_config::resolve(config.lsp).unwrap_err(),
"LSP configuration supports at most 16 configured servers"
);
frame.as_object_mut().unwrap().remove("lsp");
+22 -1
View File
@@ -141,6 +141,8 @@ module Vamp
@labels = {}
@diff_context_lines = 10
@diff_expand_lines = 15
@diff_view_mode = "split"
@diff_split_orientation = "left_right"
end
# @param path [String] relative to the configuration file
@@ -205,6 +207,24 @@ module Vamp
@diff_expand_lines = diff_line_count!(lines, "diff_expand_lines", 1)
end
# @param mode [String] "split" or "unified"
# @return [String]
def diff_view_mode=(mode)
mode = nonempty_string!(mode, "diff view mode")
raise ArgumentError, "diff view mode must be split or unified" unless %w[split unified].include?(mode)
@diff_view_mode = mode
end
# @param orientation [String] "left_right" or "above_below"
# @return [String]
def diff_split_orientation=(orientation)
orientation = nonempty_string!(orientation, "diff split orientation")
unless %w[left_right above_below].include?(orientation)
raise ArgumentError, "diff split orientation must be left_right or above_below"
end
@diff_split_orientation = orientation
end
# @return [Module<Vamp::Core>] frozen namespace with catalog-generated Action methods
def core
Core
@@ -238,7 +258,8 @@ module Vamp
"file_explorer_view" => @file_explorer_view, "clipboard_mode" => @clipboard_mode,
"top_bar" => @bar.top, "bottom_bar" => @bar.bottom, "labels" => @labels,
"popup" => { "command" => @popup.command, "search" => @popup.search },
"diff_context_lines" => @diff_context_lines, "diff_expand_lines" => @diff_expand_lines }
"diff_context_lines" => @diff_context_lines, "diff_expand_lines" => @diff_expand_lines,
"diff_view_mode" => @diff_view_mode, "diff_split_orientation" => @diff_split_orientation }
end
private
+73
View File
@@ -225,6 +225,7 @@ impl EditorSession {
));
}
editor.set_diff_context_options(config.diff_context_lines, config.diff_expand_lines);
editor.set_diff_display_options(config.diff_view_mode, config.diff_split_orientation);
if let Err(error) =
editor.set_chrome_config(config.top_bar, config.bottom_bar, config.labels)
{
@@ -1815,6 +1816,46 @@ end
assert_eq!(session.editor().lines(), &["a!bc"]);
}
#[test]
fn diff_display_preferences_survive_reopening() {
use crate::{diff::ViewMode, panes::SplitAxis};
let fixture = Fixture::new();
fixture.file("buffer.txt", "abc");
for (mode, orientation, expected_mode, expected_axis) in [
(
"unified",
"above_below",
ViewMode::Unified,
SplitAxis::Horizontal,
),
(
"split",
"above_below",
ViewMode::Split,
SplitAxis::Horizontal,
),
] {
let mut session = fixture.open(&format!(
r#"Vamp.configure do |config|
config.diff_view_mode = "{mode}"
config.diff_split_orientation = "{orientation}"
config.bind "n", config.core.right
end"#
));
session.handle(Input::Char('n'));
assert_eq!(session.editor().cursor().column, 1);
for _ in 0..2 {
session.execute("diff");
let diff = session.editor().diff().unwrap();
assert_eq!(diff.view_mode, expected_mode);
assert_eq!(diff.split_axis, expected_axis);
session.execute("diff.close");
assert!(session.editor().diff().is_none());
assert_eq!(session.editor().lines(), &["abc"]);
}
}
}
#[test]
fn no_config_uses_space_leader_for_palette_and_write() {
let fixture = Fixture::new();
@@ -3008,6 +3049,38 @@ end
("config.leader = 7", "leader key must be a String"),
("config.leader = nil", "leader key must be a String"),
(r#"config.leader = """#, "leader key must not be empty"),
(
r#"config.diff_view_mode = "invalid""#,
"diff view mode must be split or unified",
),
(
r#"config.diff_split_orientation = "invalid""#,
"diff split orientation must be left_right or above_below",
),
(
"config.diff_view_mode = 7",
"diff view mode must be a String",
),
(
"config.diff_view_mode = nil",
"diff view mode must be a String",
),
(
r#"config.diff_view_mode = """#,
"diff view mode must not be empty",
),
(
"config.diff_split_orientation = 7",
"diff split orientation must be a String",
),
(
"config.diff_split_orientation = nil",
"diff split orientation must be a String",
),
(
r#"config.diff_split_orientation = """#,
"diff split orientation must not be empty",
),
(
"config.file_explorer_side = 7",
"file explorer side must be a String",
+276 -71
View File
@@ -34,7 +34,9 @@ use unicode_width::UnicodeWidthStr;
use crate::{
chrome::{ChromeLine, PopupAnchor, SegmentConfig},
diff::{ChangedFile, DiffState, DisplayRow, FileStatus, Focus, PaneLayout, Phase, RowKind},
diff::{
ChangedFile, DiffState, DisplayRow, FileStatus, Focus, PaneLayout, Phase, RowKind, ViewMode,
},
editor::{
Completion, Cursor, Editor, Hover, Input, Mode, Palette, Selection, StartupDiagnostic,
},
@@ -2041,6 +2043,9 @@ struct DiffGeometry {
viewport: Option<(u64, usize, usize, usize, usize)>,
text_widths: [u16; 2],
wrapping: bool,
view_mode: ViewMode,
split_axis: SplitAxis,
caret_row: Option<usize>,
}
fn diff_panel(
@@ -2270,14 +2275,7 @@ fn paint_diff_editor(
let cursor_column = display_column(&editor.lines()[source_cursor.row], source_cursor.column);
let mut cursor = None;
for (screen, display, segments) in diff_screen_rows(diff, top, area.height, wrapped) {
let DisplayRow::Line(aligned) = diff.display_rows[display] else {
continue;
};
let Some(source) = (match focus {
Focus::Before => diff.rows[aligned].before,
Focus::After => diff.rows[aligned].after,
Focus::Files => None,
}) else {
let Some(source) = diff.display_source_row(focus, display) else {
continue;
};
let mut line_area = Rect::new(area.x, area.y + screen as u16, area.width, 1);
@@ -2524,6 +2522,9 @@ fn draw_diff(
viewport: None,
text_widths: [0; 2],
wrapping: diff.word_wrap,
view_mode: diff.view_mode,
split_axis: diff.split_axis,
caret_row: None,
});
if let Some(editor) = editor {
draw_diff_popups(frame, editor, area, None, panels);
@@ -2572,10 +2573,15 @@ fn draw_diff(
(workspace, None)
};
let pane_layout = diff.pane_layout();
let [before_area, after_area] = match pane_layout {
PaneLayout::Split => split_rect(pane_rect(panes), SplitAxis::Vertical).map(terminal_rect),
PaneLayout::Before | PaneLayout::Empty => [panes, Rect::default()],
PaneLayout::After => [Rect::default(), panes],
let unified = diff.view_mode == ViewMode::Unified;
let [before_area, after_area] = if unified {
[panes, panes]
} else {
match pane_layout {
PaneLayout::Split => split_rect(pane_rect(panes), diff.split_axis).map(terminal_rect),
PaneLayout::Before | PaneLayout::Empty => [panes, Rect::default()],
PaneLayout::After => [Rect::default(), panes],
}
};
let mut document_caret = None;
let mut top = diff.top;
@@ -2586,7 +2592,9 @@ fn draw_diff(
.previous_diff
.as_ref()
.filter(|previous| {
previous.wrapping != wrapping
(previous.wrapping != wrapping
|| previous.view_mode != diff.view_mode
|| previous.split_axis != diff.split_axis)
&& previous.viewport.is_some_and(|(id, file, _, _, _)| {
Some(id) == diff.comparison_id && file == diff.file
})
@@ -2594,6 +2602,9 @@ fn draw_diff(
.and_then(|previous| {
let editor = editor?;
let focus = editor.diff_buffer_focus()?;
if previous.view_mode != diff.view_mode || previous.split_axis != diff.split_axis {
return previous.caret_row;
}
let cursor = editor.cursor();
if previous.wrapping {
let file = selected?;
@@ -2635,7 +2646,7 @@ fn draw_diff(
Focus::After => (after_area, &mut after_left),
Focus::Files => unreachable!("native diff buffers belong to a text pane"),
};
let height = usize::from(pane.height.saturating_sub(2));
let height = usize::from(diff_inner(pane).height);
if let Some(row) = diff.source_display_row(focus, editor.cursor().row) {
if let Some(anchor) = toggle_row {
top = row.saturating_sub(anchor.min(height.saturating_sub(1)));
@@ -2651,14 +2662,10 @@ fn draw_diff(
Focus::After => file.after.len(),
Focus::Files => unreachable!("native buffer belongs to a text pane"),
});
let digits = source_len.max(1).ilog10() as usize + 1;
let content_width = usize::from(pane.width.saturating_sub(2));
let gutter = if content_width > digits + 2 {
digits + 2
} else {
0
};
let width = content_width.saturating_sub(gutter);
let width = selected.map_or_else(
|| diff_text_width(pane, source_len),
|file| diff_pane_text_width(diff, file, pane, focus),
);
let column = display_column(&editor.lines()[editor.cursor().row], editor.cursor().column);
if column < *left {
*left = column;
@@ -2721,15 +2728,18 @@ fn draw_diff(
if pane_layout == PaneLayout::Empty {
diff_pane_panel(frame, diff, Some(file), before_area, true);
} else {
if before_area != Rect::default() {
if before_area != Rect::default() && (!unified || diff.focus == Focus::Before) {
let text_area = draw_diff_pane(
frame,
diff,
file,
before_area,
true,
&syntax.before,
editor.map_or(&[], |editor| editor.diff_semantic_tokens(Focus::Before)),
[&syntax.before, &syntax.after],
[
editor.map_or(&[], |editor| editor.diff_semantic_tokens(Focus::Before)),
editor.map_or(&[], |editor| editor.diff_semantic_tokens(Focus::After)),
],
(
top,
&mut before_left,
@@ -2754,15 +2764,18 @@ fn draw_diff(
.map(|position| (text_area, position));
}
}
if after_area != Rect::default() {
if after_area != Rect::default() && (!unified || diff.focus != Focus::Before) {
let text_area = draw_diff_pane(
frame,
diff,
file,
after_area,
false,
&syntax.after,
editor.map_or(&[], |editor| editor.diff_semantic_tokens(Focus::After)),
[&syntax.before, &syntax.after],
[
editor.map_or(&[], |editor| editor.diff_semantic_tokens(Focus::Before)),
editor.map_or(&[], |editor| editor.diff_semantic_tokens(Focus::After)),
],
(
top,
&mut after_left,
@@ -2804,14 +2817,32 @@ fn draw_diff(
panels.diff = Some(DiffGeometry {
regions: [
(navigator.unwrap_or_default(), Focus::Files),
(before_area, Focus::Before),
(after_area, Focus::After),
(
if unified && diff.focus != Focus::Before {
Rect::default()
} else {
before_area
},
Focus::Before,
),
(
if unified && diff.focus == Focus::Before {
Rect::default()
} else {
after_area
},
Focus::After,
),
],
viewport: diff
.comparison_id
.map(|id| (id, diff.file, top, before_left, after_left)),
text_widths,
wrapping,
view_mode: diff.view_mode,
split_axis: diff.split_axis,
caret_row: document_caret
.map(|(text_area, caret)| usize::from(caret.y.saturating_sub(text_area.y))),
});
if let Some(editor) = editor {
draw_diff_popups(frame, editor, area, document_caret, panels);
@@ -2840,7 +2871,17 @@ fn diff_pane_panel(
let title = file.map_or_else(
|| " Diff ".into(),
|file| {
let label = if before { "Before" } else { "After" };
let label = if diff.view_mode == ViewMode::Unified {
if before {
"Unified · Before"
} else {
"Unified · After"
}
} else if before {
"Before"
} else {
"After"
};
let path = if before {
file.old_path.as_ref().unwrap_or(&file.path)
} else {
@@ -2857,7 +2898,7 @@ fn diff_pane_panel(
.comparison
.as_ref()
.map_or_else(String::new, |comparison| {
if diff.pane_layout() == PaneLayout::Split {
if diff.view_mode == ViewMode::Split && diff.pane_layout() == PaneLayout::Split {
format!(
" {} ",
display_text(if before {
@@ -2890,8 +2931,8 @@ fn draw_diff_pane(
file: &ChangedFile,
area: Rect,
before: bool,
syntax: &SyntaxCache,
semantic: &[SemanticToken],
syntax: [&SyntaxCache; 2],
semantic: [&[SemanticToken]; 2],
viewport: (usize, &mut usize, Option<u16>),
wrapped: Option<&[WrappedDiffRow]>,
colors: DiffColors,
@@ -2915,14 +2956,9 @@ fn draw_diff_pane(
);
return content;
}
let source = if before { &file.before } else { &file.after };
let unified = diff.view_mode == ViewMode::Unified;
let (top, left, previous_width) = viewport;
let digits = source.len().max(1).ilog10() as usize + 1;
let gutter = if usize::from(content.width) > digits + 2 {
digits + 2
} else {
0
};
let gutter = diff_gutter(diff, file, usize::from(content.width), focus);
let width = usize::from(content.width).saturating_sub(gutter);
if wrapped.is_none() && previous_width.is_some_and(|previous| usize::from(previous) != width) {
*left = diff.bound_horizontal(focus, *left, width);
@@ -2946,7 +2982,16 @@ fn draw_diff_pane(
}
};
let row = &diff.rows[aligned_row];
let index = if before { row.before } else { row.after };
let before = if unified {
diff.display_focus(display_row).unwrap_or(focus) == Focus::Before
} else {
before
};
let source = if before { &file.before } else { &file.after };
let index = diff.display_source_row(
if before { Focus::Before } else { Focus::After },
display_row,
);
let Some(index) = index else {
let style = Style::default().fg(Color::Rgb(72, 72, 72)).bg(Color::Reset);
let buffer = frame.buffer_mut();
@@ -3013,14 +3058,47 @@ fn draw_diff_pane(
} else {
Color::DarkGray
};
let label = if unified {
let old = diff
.display_source_row(Focus::Before, display_row)
.map(|row| row + 1);
let new = diff
.display_source_row(Focus::After, display_row)
.map(|row| row + 1);
let old_digits = file.before.len().max(1).ilog10() as usize + 1;
let new_digits = file.after.len().max(1).ilog10() as usize + 1;
match (old, new) {
(Some(old), Some(new)) => {
format!("{old:>old_digits$} {new:>new_digits$}{marker} ")
}
(Some(old), None) => format!("{old:>old_digits$} {:>new_digits$}{marker} ", ""),
(None, Some(new)) => format!("{:>old_digits$} {new:>new_digits$}{marker} ", ""),
(None, None) => unreachable!("displayed diff line has a source"),
}
} else {
let digits = source.len().max(1).ilog10() as usize + 1;
format!("{:>digits$}{marker} ", index + 1)
};
frame.render_widget(
Paragraph::new(format!("{:>digits$}{marker} ", index + 1))
.style(Style::default().fg(color).bg(background)),
Paragraph::new(label).style(Style::default().fg(color).bg(background)),
Rect::new(content.x, row_area.y, gutter as u16, 1),
);
}
paint_syntax_line(frame, source_text, syntax.line(index), text_area, left);
paint_semantic_line(frame, source_text, semantic, index, text_area, left);
paint_syntax_line(
frame,
source_text,
syntax[usize::from(!before)].line(index),
text_area,
left,
);
paint_semantic_line(
frame,
source_text,
semantic[usize::from(!before)],
index,
text_area,
left,
);
if !matches!(file.status, FileStatus::Added | FileStatus::Removed) {
let style = Style::default().bg(colors.character[usize::from(!before)]);
for change in &row.changes {
@@ -3122,6 +3200,7 @@ struct WrappedDiffRow {
#[derive(Default)]
struct DiffWrapViewport {
key: Option<(u64, usize, Option<u64>)>,
view_mode: Option<ViewMode>,
revision: u64,
before: crate::wrap::WrapCache,
after: crate::wrap::WrapCache,
@@ -3144,6 +3223,22 @@ fn diff_text_width(area: Rect, source_len: usize) -> usize {
width.saturating_sub(if width > digits + 2 { digits + 2 } else { 0 })
}
fn diff_gutter(diff: &DiffState, file: &ChangedFile, width: usize, focus: Focus) -> usize {
let old = file.before.len().max(1).ilog10() as usize + 1;
let new = file.after.len().max(1).ilog10() as usize + 1;
let gutter = if diff.view_mode == ViewMode::Unified {
old + new + 3
} else {
(if focus == Focus::Before { old } else { new }) + 2
};
if width > gutter { gutter } else { 0 }
}
fn diff_pane_text_width(diff: &DiffState, file: &ChangedFile, area: Rect, focus: Focus) -> usize {
let width = usize::from(diff_inner(area).width);
width.saturating_sub(diff_gutter(diff, file, width, focus))
}
impl DiffWrapViewport {
fn row(
&mut self,
@@ -3153,9 +3248,12 @@ impl DiffWrapViewport {
) -> WrappedDiffRow {
let mut segments = [None; 2];
let mut final_segments = [false; 2];
if let Some(DisplayRow::Line(index)) = diff.display_rows.get(position.display) {
let aligned = &diff.rows[*index];
for (side, source) in [aligned.before, aligned.after].into_iter().enumerate() {
if matches!(
diff.display_rows.get(position.display),
Some(DisplayRow::Line(_))
) {
for (side, focus) in [Focus::Before, Focus::After].into_iter().enumerate() {
let source = diff.display_source_row(focus, position.display);
if let Some(source) = source.filter(|_| self.widths[side] > 0) {
let (cache, lines) = if side == 0 {
(&mut self.before, &file.before)
@@ -3177,12 +3275,12 @@ impl DiffWrapViewport {
}
fn height(&mut self, diff: &DiffState, file: &ChangedFile, display: usize) -> usize {
let Some(DisplayRow::Line(index)) = diff.display_rows.get(display) else {
if !matches!(diff.display_rows.get(display), Some(DisplayRow::Line(_))) {
return 1;
};
let aligned = &diff.rows[*index];
}
let mut height = 1;
for (side, source) in [aligned.before, aligned.after].into_iter().enumerate() {
for (side, focus) in [Focus::Before, Focus::After].into_iter().enumerate() {
let source = diff.display_source_row(focus, display);
if let Some(source) = source.filter(|_| self.widths[side] > 0) {
let (cache, lines) = if side == 0 {
(&mut self.before, &file.before)
@@ -3273,12 +3371,14 @@ impl DiffWrapViewport {
let key = Some((id, diff.file, diff.live_revision));
if self.key.map(|(id, file, _)| (id, file)) != Some((id, diff.file))
|| self.top.display != diff.top
|| self.view_mode != Some(diff.view_mode)
{
self.top = DiffWrapPosition {
display: diff.top,
continuation: 0,
};
}
self.view_mode = Some(diff.view_mode);
if self.key != key {
self.key = key;
self.revision = self
@@ -3287,8 +3387,8 @@ impl DiffWrapViewport {
.expect("wrap revisions exhausted");
}
self.widths = [
diff_text_width(areas[0], file.before.len()),
diff_text_width(areas[1], file.after.len()),
diff_pane_text_width(diff, file, areas[0], Focus::Before),
diff_pane_text_width(diff, file, areas[1], Focus::After),
];
self.before.prepare((diff.file, id), self.widths[0]);
self.after
@@ -3301,8 +3401,11 @@ impl DiffWrapViewport {
.top
.continuation
.min(self.height(diff, file, self.top.display) - 1);
let height = usize::from(diff_inner(areas[0]).height.max(diff_inner(areas[1]).height));
self.fill(diff, file, height);
let active = usize::from(diff.focus == Focus::After || areas[0].height == 0);
let height = usize::from(diff_inner(areas[active]).height);
let paint_height =
usize::from(diff_inner(areas[0]).height.max(diff_inner(areas[1]).height));
self.fill(diff, file, paint_height);
if let Some((focus, cursor)) = caret
&& let Some(position) = self.caret_position(diff, file, focus, cursor)
{
@@ -3311,16 +3414,21 @@ impl DiffWrapViewport {
for _ in 0..row.min(height.saturating_sub(1)) {
self.top = self.step(diff, file, self.top, false);
}
self.fill(diff, file, height);
self.fill(diff, file, paint_height);
} else if position < self.top {
self.top = position;
self.fill(diff, file, height);
} else if !self.rows.iter().any(|row| row.position == position) {
self.fill(diff, file, paint_height);
} else if !self
.rows
.iter()
.take(height)
.any(|row| row.position == position)
{
self.top = position;
for _ in 1..height {
self.top = self.step(diff, file, self.top, false);
}
self.fill(diff, file, height);
self.fill(diff, file, paint_height);
}
}
}
@@ -3433,15 +3541,9 @@ fn diff_visible_rows(
let count = wrapped.map_or(usize::from(height), |rows| {
rows.last().map_or(0, |row| row.position.display - top + 1)
});
for row in diff.display_rows.iter().skip(top).take(count) {
let DisplayRow::Line(index) = *row else {
continue;
};
let source = if before {
diff.rows[index].before
} else {
diff.rows[index].after
};
for display in top..top.saturating_add(count).min(diff.display_rows.len()) {
let source =
diff.display_source_row(if before { Focus::Before } else { Focus::After }, display);
if let Some(source) = source {
if let Some(last) = ranges.last_mut()
&& last.end == source
@@ -8068,6 +8170,109 @@ mod tests {
}
}
#[test]
fn horizontal_diff_split_keeps_after_caret_in_smaller_bottom_pane() {
let before = (0..35)
.map(|row| format!("old{row}"))
.collect::<Vec<_>>()
.join("\n");
let after = before.replace("old", "new");
let mut editor = readonly_diff_editor(syntax_diff_file("axis.txt", &before, &after));
editor.handle(Input::Char('\t'));
editor.handle(Input::Char('\t'));
for _ in 0..20 {
editor.handle(Input::Down);
}
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 17)).unwrap();
let mut views = BufferViewports::default();
render_native_diff(&mut terminal, &mut views, &mut editor);
editor.execute("diff.orientation.toggle");
let caret = render_native_diff(&mut terminal, &mut views, &mut editor);
let geometry = views.panels.diff.as_ref().unwrap();
assert_eq!(geometry.regions[1].0.x, geometry.regions[2].0.x);
assert!(geometry.regions[1].0.bottom() <= geometry.regions[2].0.y);
assert!(diff_inner(geometry.regions[2].0).contains(caret));
editor.execute("word_wrap.toggle");
let caret = render_native_diff(&mut terminal, &mut views, &mut editor);
assert!(diff_inner(views.panels.diff.as_ref().unwrap().regions[2].0).contains(caret));
}
#[test]
fn unified_diff_renders_ordered_coordinates_and_switches_logical_focus() {
let mut file = syntax_diff_file("unified.txt", "same\nold\nend", "same\nnew\nend");
file.hunks = crate::diff::line_hunks(&file.before, false, &file.after, false);
let mut editor = readonly_diff_editor(file);
editor.execute("diff.view.toggle");
editor.handle(Input::Char('\t'));
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 20)).unwrap();
let mut views = BufferViewports::default();
render_native_diff(&mut terminal, &mut views, &mut editor);
let geometry = views.panels.diff.as_ref().unwrap();
assert_eq!(geometry.regions[2].0, Rect::default());
let area = diff_inner(geometry.regions[1].0);
let buffer = terminal.backend().buffer();
let lines = (area.y..area.bottom())
.map(|y| {
(area.x..area.right())
.map(|x| buffer[(x, y)].symbol())
.collect::<String>()
})
.collect::<Vec<_>>();
let tokens: Vec<Vec<&str>> = lines[..4]
.iter()
.map(|line| line.split_whitespace().collect())
.collect();
assert_eq!(
tokens,
[
vec!["1", "1", "same"],
vec!["2", "-", "old"],
vec!["2+", "new"],
vec!["3", "3", "end"]
]
);
assert!(!lines.iter().any(|line| line.contains('╱')));
editor.handle(Input::Char('\t'));
render_native_diff(&mut terminal, &mut views, &mut editor);
let geometry = views.panels.diff.as_ref().unwrap();
assert_eq!(geometry.regions[1].0, Rect::default());
assert_eq!(diff_inner(geometry.regions[2].0), area);
}
#[test]
fn unified_wrapping_uses_only_the_display_rows_source_side() {
let before = format!("{}\nend", "old ".repeat(40));
let mut file = syntax_diff_file("wrap.txt", &before, "new\nend");
file.hunks = crate::diff::line_hunks(&file.before, false, &file.after, false);
let mut editor = readonly_diff_editor(file);
editor.execute("diff.view.toggle");
editor.execute("word_wrap.toggle");
editor.handle(Input::Char('\t'));
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 30)).unwrap();
let mut views = BufferViewports::default();
render_native_diff(&mut terminal, &mut views, &mut editor);
let wrap = views.panels.diff_wrap.as_ref().unwrap();
let removed = wrap
.rows
.iter()
.filter(|row| row.position.display == 0)
.collect::<Vec<_>>();
assert!(removed.len() > 1);
assert!(
removed
.iter()
.all(|row| row.segments[0].is_some() && row.segments[1].is_none())
);
let added = wrap
.rows
.iter()
.filter(|row| row.position.display == 1)
.collect::<Vec<_>>();
assert_eq!(added.len(), 1);
assert!(added[0].segments[0].is_none());
assert!(added[0].segments[1].is_some());
}
#[test]
fn wrapped_diff_aligns_continuations_and_preserves_native_selection_and_caret() {
let before = format!("// {}BEFORE_END\nsentinel", "old ".repeat(70));