Compare commits

...

2 Commits

14 changed files with 855 additions and 169 deletions
+1
View File
@@ -37,6 +37,7 @@ Vamp starts in Normal mode. Press `i`, type text, then Esc to return to Normal.
| Command palette | Ctrl+p; type to filter, arrows select, Enter accepts, Esc cancels |
| Floating/docked explorer in Normal or Visual | Space then `o` / Space then `e` |
| Repository diff in Normal | `:diff`; choose Before then After, Tab changes category, type to search; Esc closes the selector; Working checkout After is editable; `:diff.close` exits the comparison |
| Diff layout in Normal | Space then `v` cycles split/unified; Space then `s` flips left/right vs above/below split |
Space is the default leader: press its suffix keys in order, not simultaneously. The [editing guide](docs/editing.md) contains the complete key table and behavior reference.
+4 -2
View File
@@ -14,8 +14,10 @@
- changed symbol ranges stay in the comparison; other targets open ordinary tabs. Historical text stays immutable; source freshness and failure atomicity are preserved.
- [x] better syntax highlighting for markdown files. color `-` and `1.` from lists.
- [x] automatically reload buffers when underlying file changes outside of vamp if buffer is clean. if buffer is dirty, reject normal write attempts with warning and require forceful write to overwrite underlying file contents.
- [ ] diff view: change diff view modes; choose between split and unified diff view (split by default).
- [ ] diff view: for split view, change split orientation (left/right vs above/below) - left/right by default.
- [x] diff view: change diff view modes; choose between split and unified diff view (split by default; some key cycles; default should be configurable).
- Space v cycles split/unified; configurable startup default, dual line numbers, native side focus/editing, and shared highlighting/wrap/excerpts.
- [x] diff view: for split view, change split orientation (left/right vs above/below) - left/right by default.
- Space s flips orientation; configurable startup default and shared pane geometry/directional focus.
- [ ] diff view: github integration (code review) - add comments on a pr, add review comments at specific lines of files, request changes, approve pr, etc.
- [ ] diff view: local review comments (same features as for github reviews) that allows review of local changes to deliver specific reviews to ai agents used for coding.
- design question: would this require a server that the agent can interact with, or would local review files be fine? don't want to use server client stuff, if it would be easier and better to just use local files.
+2
View File
@@ -73,6 +73,8 @@ The TUI allocates a flexible buffer viewport and optional one-row top/bottom bar
Comparison views reserve no separate metadata header or native-buffer help footer. Revision labels render bottom-left in pane borders (combined on single-pane views), with per-side editability top-right and focus indicated by border styling. Dock and document geometry use the reclaimed workspace rows; conditional loading/error/file notices remain separate from the border metadata.
Each comparison owns its split/unified mode and split axis; startup defaults apply only when opening a comparison. Source alignment and file-side availability stay independent of presentation. Unified display rows carry a side projection: equal context maps both sources, changed runs emit Before rows followed by After rows, and folds retain aligned coordinates. Native navigation/editing continues to bind source buffers rather than a synthetic unified document. The TUI reuses side-specific syntax/wrap/overlay pipelines in one unified region or the existing axis-based split geometry, exposing only the active logical side for unified hit-testing and directional focus.
Mouse wheel input uses the last rendered document/panel rectangles for hit-testing. Document offsets and explorer scroll state remain frontend-owned; three-row wheel steps never become document cursor motions. Manual document views persist until logical caret-reveal, caret/selection, mode, revision, or buffer changes; offscreen primary and browsing-selection cursors are not placed. Hover wheel input reuses the existing editor-owned popup scrolling.
Already-ready mouse reports share a redraw: at most 64 reports are applied in their original order before painting, without dropping or summing away wheel steps. Readiness checks do not wait. The first non-mouse event is retained for the next iteration after rendering, so keys, paste, and resizes cannot reuse geometry from before their layout changes. The batch bound keeps continuous mouse input from starving drawing or background polling.
+11 -2
View File
@@ -18,6 +18,8 @@ Vamp.configure do |config|
config.file_picker_view = "tree"
config.diff_context_lines = 10
config.diff_expand_lines = 15
config.diff_view_mode = "split" # or "unified"
config.diff_split_orientation = "vertical" # or "horizontal"
config.bind "ctrl+h", config.core.left, mode: config.modes.tree
config.bind "ctrl+l", config.core.right, mode: config.modes.tree
config.plugin "plugins/greeting.rb"
@@ -44,6 +46,13 @@ Plugin paths resolve relative to the config file, not the shell's working direct
`config.diff_context_lines = 10` sets how many unchanged aligned lines appear above and below each diff change; it defaults to `10` and accepts nonnegative integers, including `0` for changes only. `config.diff_expand_lines = 15` sets how many additional lines each directional expansion reveals per keypress; it defaults to `15` and accepts positive integers. Both are bounded by the platform's Rust `usize` maximum (`18446744073709551615` on 64-bit systems). Floats, strings, booleans, negative or oversized values, and a zero expansion step reject the whole Ruby configuration.
`config.diff_view_mode` selects the initial view for each new comparison: `"split"` (default) or `"unified"`. `config.diff_split_orientation` selects `"vertical"` (left/right, default) or `"horizontal"` (above/below) for split views. Invalid values reject the configuration. Normal-mode **Space v** cycles split/unified; **Space s** flips split orientation. These controls affect the current comparison, not startup defaults, and retain their choices across file switches and tabs. Orientation changes apply only in split view.
```ruby
config.bind "leader v", config.core.diff.view.toggle
config.bind "leader s", config.core.diff.orientation.toggle
```
### Top and bottom bars
`config.bar.top` and `config.bar.bottom` each accept ordered `left`, `center`, and `right` arrays of segment names. Both bars accept every builtin and custom segment, with the same anchors, title templates, padding, styled edges, and collision handling. Left and right groups anchor to their respective edges; the center group is centered on the whole row, not on the gap remaining between the other groups. Array order is display order within each group. Omitted groups and segment options retain that bar's defaults; an empty array removes that group.
@@ -151,7 +160,7 @@ Normal `dd` and `cc` delete/change complete rows, with leading counts. Bind `con
| Normal | `save`, `force_save`, `quit`, `force_quit`, `restart`, `force_restart`, `save_and_quit`, `force_save_and_quit`, `plugins` |
| Normal | `edit`, `buffers`, `buffer`, `buffer_next`, `buffer_previous`, `buffer_delete`, `buffer_force_delete` |
| Normal | `follow_link` |
| Normal | `diff` |
| Normal | `diff`, `diff.view.toggle`, `diff.orientation.toggle` |
| Normal, Insert, Visual, Command, Search | `diff.close` |
| Normal, Insert, Visual, Command, Search | `word_wrap.toggle` |
| Normal | `pane.split_horizontal`, `pane.split_vertical`, `pane.focus_left`, `pane.focus_down`, `pane.focus_up`, `pane.focus_right`, `pane.close`, `pane.force_close` |
@@ -228,7 +237,7 @@ config.bind "ctrl+h", nil, mode: config.modes.tree
The diff's changed-file explorer and floating picker use these same tree and selection contexts, including remaps and disabled keys. Enter opens the selected file's comparison; Working checkout After reuses its editable buffer. The catalog contains only changed paths, with A/M/D/R status markers, rather than a disk scan.
Ordinary `config.core.file_picker` and `config.core.file_explorer` bindings work inside comparisons. Ctrl+w closes a focused picker/explorer, retaining its query, selection, and expansion state; the panes use the reclaimed width. Ctrl+e has no reserved diff behavior. Escape closes the revision selector in either stage, but an opened comparison still closes explicitly through `:diff.close`, its palette action, or the generated action value below. `config.core.diff` remains the opening action and also exposes its immutable `close` child.
Ordinary `config.core.file_picker` and `config.core.file_explorer` bindings work inside comparisons. Ctrl+w closes a focused picker/explorer, retaining its query, selection, and expansion state; the panes use the reclaimed width. Ctrl+e has no reserved diff behavior. Escape closes the revision selector in either stage, but an opened comparison still closes explicitly through `:diff.close`, its palette action, or the generated action value below. `config.core.diff` remains the opening action and exposes immutable `close`, `view.toggle`, and `orientation.toggle` children.
`config.core.pane.close` and `config.core.pane.force_close` close a focused **docked** file list instead of its editor pane, without discarding buffers or exiting the comparison. A floating picker does not intercept these actions: normal close still protects underlying dirty buffers, while force-close retains its existing discard semantics. For example, bind `config.bind "ctrl+x", config.core.pane.close`; focused tree/selection overrides and printable filter input retain priority. Palette-originated close actions retain the originating dock target through force confirmation.
+5 -1
View File
@@ -327,7 +327,11 @@ The selector initially lists local revisions and known tracking refs **without c
Comparison discovery builds the changed-path/status catalog first. File text, notices, and hunks load asynchronously when a file is selected and remain cached for that comparison. Failed file loads can be retried by selecting the file again; cancelled or superseded results cannot replace the current selection. Working-checkout comparisons still capture working bytes and the historical content needed to identify changes, preserving the frozen Before snapshot and unsaved After buffers.
The changed-file explorer is **docked on the right**. Modified files show Before on the left and After on the right. Added files show only After, using the full text workspace; deleted files show only Before. Rename-only files show one empty pane, with the old and new paths in the explorer; renames with text or final-newline changes retain the split view. Before and historical/remote After snapshots are read-only. Selecting **Working checkout** as After binds eligible regular UTF-8 files to normal editor buffers, reusing already-open dirty buffers and their undo history. Editable After supports native edits, completion, undo/redo, and `:w`. Removed files, rename-only files, binary files, symlinks, and submodules remain read-only. Closing the diff keeps its buffers and unsaved edits.
The changed-file explorer is **docked on the right**. In the default split layout, modified files show Before on the left and After on the right. Added files show only After, using the full text workspace; deleted files show only Before. Rename-only files show one empty pane, with the old and new paths in the explorer; renames with text or final-newline changes retain both source sides. Before and historical/remote After snapshots are read-only. Selecting **Working checkout** as After binds eligible regular UTF-8 files to normal editor buffers, reusing already-open dirty buffers and their undo history. Editable After supports native edits, completion, undo/redo, and `:w`. Removed files, rename-only files, binary files, symlinks, and submodules remain read-only. Closing the diff keeps its buffers and unsaved edits.
Comparisons start in **split, vertical (left/right)** view by default. In Normal mode, **Space v** cycles split/unified; **Space s** switches a split between vertical (left/right) and horizontal (above/below, Before above After). Canonical commands are `:diff.view.toggle` and `:diff.orientation.toggle`; both are available in the palette and as remappable `config.core.diff.view.toggle` / `config.core.diff.orientation.toggle` actions. Configure new-comparison defaults with `config.diff_view_mode = "split"` or `"unified"` and `config.diff_split_orientation = "vertical"` or `"horizontal"`. Current choices survive file and tab switches; closing and reopening uses the configured defaults. Orientation stays unchanged while unified is active.
**Unified** uses one full-width text pane with both original line-number columns: context appears once, then each changed run shows removed (`-`) lines followed by added (`+`) lines, without alignment holes. Tab/Shift+Tab still switches logical Before/After focus, preserving native source coordinates, read-only protection, and Working After editing. The border shows the combined revisions and the focused side's editability. Word wrap, syntax/semantic and character highlighting, search, selections, excerpt expansion, and wheel scrolling work in either view.
Diff metadata lives in the text-pane borders instead of a separate info row: split panes show their own Before/After revision labels at the bottom-left; a single visible pane shows the combined `before → after` label there. Each pane shows its read-only/editable state at the top-right, independently of keyboard focus. Before is always read-only; After is editable only for an eligible Working-checkout file. The changed-file explorer and panes start directly below configured chrome, and there is no native-buffer help footer. Loading/error/file notices remain visible when applicable. Narrow borders prioritize state and clip titles without painting over the gutter or source text.
+3 -1
View File
@@ -7,6 +7,8 @@ Vamp.configure do |config|
config.file_explorer_side = "right"
config.file_explorer_view = "tree"
config.file_picker_view = "tree"
config.diff_view_mode = "split" # or "unified"
config.diff_split_orientation = "vertical" # or "horizontal"
# config.popup.command = { anchor: :bottom_left }
# config.popup.search = { anchor: :center }
# Status above, tabs and live battery below; either bar accepts all segments.
@@ -41,7 +43,7 @@ Vamp.configure do |config|
config.bind "g", "greet"
config.bind "leader g", "greet"
config.bind "leader q", config.core.quit
config.bind "leader v", config.core.diff
config.bind "leader d", config.core.diff
config.bind "ctrl+r", config.core.redo
config.bind "leader r", config.core.repeat_last_edit
config.bind "ctrl+c", config.core.normal, mode: config.modes.insert
+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 = \"vertical\"
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
+2 -1
View File
@@ -1,6 +1,7 @@
//! Frontend-independent binary pane layout and spatial focus navigation.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SplitAxis {
/// Above/below.
Horizontal,
+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":"vertical"}"#,
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":"vertical"}"#,
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":"vertical"}"#,
] {
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": "vertical"
});
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": "vertical"
});
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 = "vertical"
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] "vertical" or "horizontal"
# @return [String]
def diff_split_orientation=(orientation)
orientation = nonempty_string!(orientation, "diff split orientation")
unless %w[vertical horizontal].include?(orientation)
raise ArgumentError, "diff split orientation must be vertical or horizontal"
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
+82
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,47 @@ 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",
"horizontal",
ViewMode::Unified,
SplitAxis::Horizontal,
),
(
"split",
"horizontal",
ViewMode::Split,
SplitAxis::Horizontal,
),
("split", "vertical", ViewMode::Split, SplitAxis::Vertical),
] {
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 +3050,46 @@ 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 vertical or horizontal",
),
(
r#"config.diff_split_orientation = "left_right""#,
"diff split orientation must be vertical or horizontal",
),
(
r#"config.diff_split_orientation = "above_below""#,
"diff split orientation must be vertical or horizontal",
),
(
"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));