feat: add configurable diff modes and split orientation
This commit is contained in:
+185
-28
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user