feat: enable native readonly diff interaction and synchronized pane cursors
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
- [ ] navigation control/actions: seek occurrance of character ('f' forwards, 'F' backwards by default; allow e.g. `2f` for seeking second occurrance instead of first).
|
||||||
|
- [ ] diff view: add character-wise change highlight (enabled by default).
|
||||||
|
- [ ] 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.
|
||||||
|
- [ ] 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.
|
||||||
+7
-5
@@ -222,7 +222,9 @@ The floating selector has **Revisions / Bookmarks** tabs for jj, or **Commits /
|
|||||||
|
|
||||||
Remote references such as `some-bookmark@origin` appear from remote advertisements, including refs never fetched locally. Selecting one retrieves missing content into a temporary comparison repository. **Load history from origin** (or another configured remote) in Revisions/Commits imports searchable remote history without advancing the selection stage. These operations may require network access and the repository's existing credentials; errors stay in the selector. They do not update active refs, snapshot jj's working copy, or change the checkout.
|
Remote references such as `some-bookmark@origin` appear from remote advertisements, including refs never fetched locally. Selecting one retrieves missing content into a temporary comparison repository. **Load history from origin** (or another configured remote) in Revisions/Commits imports searchable remote history without advancing the selection stage. These operations may require network access and the repository's existing credentials; errors stay in the selector. They do not update active refs, snapshot jj's working copy, or change the checkout.
|
||||||
|
|
||||||
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. Focus After to use native editing, selections, search, 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**. 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.
|
||||||
|
|
||||||
|
Text panes use normal buffer interaction regardless of writability: motions and counts, search, Visual/Visual Line selections, multiple carets, yank, remapped bindings, and non-mutating actions work in Before and After. Read-only snapshots reject insert/delete/change/paste, repeat edits, undo/redo, mutating plugin effects, and saves, including saves to another path. Their text retains the revision's final newline. Snapshots use separate virtual LSP document identities and do not appear in ordinary buffer lists or dirty-buffer comparisons. Closing the diff disposes snapshots and restores the live or original buffer; explicit native navigation to an ordinary buffer leaves the comparison at that destination.
|
||||||
|
|
||||||
Use the ordinary **file-picker/file-explorer actions and bindings**: Space `o` opens a floating picker, Space `e` opens/focuses the docked explorer, and Ctrl+w closes the focused picker/explorer and reclaims its width. Remapped bindings work too; Ctrl+e is no longer reserved by the diff. The changed-path catalog retains A/M/D/R markers and the normal compact tree, filtering, folder expansion, and configurable `tree`/`selection` navigation. It includes removed and historical paths without scanning the working directory. Enter toggles a folder or opens the selected comparison file and focuses its first visible text pane: After for added files, otherwise Before. While the explorer is hidden, Tab/Shift+Tab cycles between visible text panes only; a single-pane view keeps its focus.
|
Use the ordinary **file-picker/file-explorer actions and bindings**: Space `o` opens a floating picker, Space `e` opens/focuses the docked explorer, and Ctrl+w closes the focused picker/explorer and reclaims its width. Remapped bindings work too; Ctrl+e is no longer reserved by the diff. The changed-path catalog retains A/M/D/R markers and the normal compact tree, filtering, folder expansion, and configurable `tree`/`selection` navigation. It includes removed and historical paths without scanning the working directory. Enter toggles a folder or opens the selected comparison file and focuses its first visible text pane: After for added files, otherwise Before. While the explorer is hidden, Tab/Shift+Tab cycles between visible text panes only; a single-pane view keeps its focus.
|
||||||
|
|
||||||
@@ -230,17 +232,17 @@ File rows show colored **A** (added, green), **M** (modified, yellow), **D** (re
|
|||||||
|
|
||||||
Text panes show change excerpts with **10 unchanged lines above and below** each change, preserving original file line numbers. Overlapping or touching excerpts merge; omitted stretches show an unchanged-line count in both panes. Files with only metadata changes show their notice or “No text changes” rather than unrelated source.
|
Text panes show change excerpts with **10 unchanged lines above and below** each change, preserving original file line numbers. Overlapping or touching excerpts merge; omitted stretches show an unchanged-line count in both panes. Files with only metadata changes show their notice or “No text changes” rather than unrelated source.
|
||||||
|
|
||||||
In **Normal mode** with a text pane focused, **`[` expands above** and **`]` expands below**, revealing up to **15 lines** per keypress. Editable After targets the excerpt containing the caret; read-only panes target the excerpt at the top of the viewport. In editable After's Insert mode these keys insert literal text. In read-only panes, a hidden-line marker targets the following excerpt, or the preceding excerpt at the end of the file. Expansion clamps at file boundaries and merges excerpts when they meet. Existing visible source stays anchored; expanding above a leading/interior marker brings the newly revealed context into view. Opening a file resets its excerpts to the configured initial context. Live edits recompute changes while retaining expanded context; the caret stays visible even when the last text change is reverted.
|
In **Normal mode** with a text pane focused, **`[` expands above** and **`]` expands below** the excerpt containing the caret, revealing up to **15 lines** per keypress in either pane. In editable After's Insert mode these keys insert literal text. Expansion clamps at file boundaries and merges excerpts when they meet. Existing visible source stays anchored. Opening a file resets its excerpts to the configured initial context. Live edits recompute changes while retaining expanded context; the caret stays visible even when the last text change is reverted.
|
||||||
|
|
||||||
Editable After navigation stays within the shown source lines. At an excerpt boundary, Up/Down or `j`/`k` jumps directly to the next/previous excerpt, and counts count visible lines rather than omitted lines. This also applies to remapped movement keys, Visual-mode movement, and Insert-mode arrows. Word motions, `gt`/`gb`, search, and occurrence selection use the visible excerpts; `gt`/`gb` target their first/last source line. Hidden short lines do not clamp the caret column. The viewport retains its vertical and horizontal offsets when movement reverses, scrolling only when the caret crosses a visible edge, as in a normal buffer. Moving or restoring a caret never reveals omitted context: only Normal-mode `[`/`]` expands existing excerpts. Edits still recompute changed regions and retain previously expanded context.
|
Text-pane navigation stays within the shown source lines. At an excerpt boundary, Up/Down or `j`/`k` jumps directly to the next/previous excerpt, and counts count visible lines rather than omitted lines. This also applies to remapped movement keys, Visual-mode movement, and editable After's Insert-mode arrows. Word motions, `gt`/`gb`, search, and occurrence selection use the visible excerpts; `gt`/`gb` target their first/last source line. Hidden short lines do not clamp the caret column. The shared vertical viewport and each pane's horizontal offset stay fixed when movement reverses, scrolling only when the caret crosses a visible edge, as in a normal buffer. Moving or restoring a caret never reveals omitted context: only Normal-mode `[`/`]` expands existing excerpts. Edits still recompute changed regions and retain previously expanded context.
|
||||||
|
|
||||||
Configure these counts in Ruby with `config.diff_context_lines = 10` (nonnegative; `0` shows changes only) and `config.diff_expand_lines = 15` (positive).
|
Configure these counts in Ruby with `config.diff_context_lines = 10` (nonnegative; `0` shows changes only) and `config.diff_expand_lines = 15` (positive).
|
||||||
|
|
||||||
In split diffs, changed text rows use muted red backgrounds in Before and muted green backgrounds in After, including blank tails and real empty lines. Entirely added or deleted files retain the normal background; colored line numbers and `+`/`-` gutter markers indicate their changes. Foreground colors use the editor's bundled Tree-sitter syntax highlighting, parsed independently for each snapshot and its path (including the old path for renames). Editable After also uses its live buffer's semantic highlighting, selections, and diagnostics. Unknown languages retain the normal foreground; historical snapshots do not borrow live-buffer LSP tokens.
|
In split diffs, changed text rows use muted red backgrounds in Before and muted green backgrounds in After, including blank tails and real empty lines. Entirely added or deleted files retain the normal background; colored line numbers and `+`/`-` gutter markers indicate their changes. Foreground colors use the editor's bundled Tree-sitter syntax highlighting, parsed independently for each snapshot and its path (including the old path for renames). Native text panes also display their buffer's selections, search matches, semantic highlighting, and diagnostics. Unknown languages retain the normal foreground; historical snapshots do not borrow live-buffer LSP tokens.
|
||||||
|
|
||||||
Missing counterparts use adjacent muted `╱` diagonals (U+2571, box drawings light diagonal) on the normal terminal background, one per column. Unlike the text slash `/`, this glyph is designed to reach cell corners so diagonals can join end to end across successive rows. Pixel-perfect joins depend on the terminal's font and line-spacing rendering. Actual empty source lines retain their normal/change background and are not hatched.
|
Missing counterparts use adjacent muted `╱` diagonals (U+2571, box drawings light diagonal) on the normal terminal background, one per column. Unlike the text slash `/`, this glyph is designed to reach cell corners so diagonals can join end to end across successive rows. Pixel-perfect joins depend on the terminal's font and line-spacing rendering. Actual empty source lines retain their normal/change background and are not hatched.
|
||||||
|
|
||||||
In Normal mode, Tab/Shift+Tab cycles focus between Files and the visible text panes, skipping absent panes. Read-only text panes use Up/Down or Ctrl+j/k for shared vertical scrolling, Left/Right or Ctrl+h/l for independent horizontal scrolling, and Home/End for the first/last displayed row; editable After uses normal buffer motions. Wheel input over text panes scrolls the visible panes without moving the editable caret; over Files it navigates visible tree rows without opening them. Escape cancels modes, prompts, or filtering, or returns picker focus to the first visible text pane; **it never closes the diff**. Use **`:diff.close`**, find `diff.close` in the palette, or bind **`config.core.diff.close`** to leave the comparison explicitly. The dotted command is intentional; there is no `:diffclose` alias.
|
In Normal mode, Tab/Shift+Tab cycles focus between Files and the visible text panes, skipping absent panes. Switching Before/After synchronizes the primary cursor by aligned diff row and display column. A missing counterpart uses the nearest visible source row; shorter lines clamp the column while retaining the preferred display column for the return trip. Wheel input scrolls the visible panes without moving the focused text caret; switching sides by wheel also synchronizes the cursor. Over Files it navigates visible tree rows without opening them. Escape cancels modes, prompts, or filtering, or returns picker focus to the last bound text pane; **it never closes the diff**. Use **`:diff.close`**, find `diff.close` in the palette, or bind **`config.core.diff.close`** to leave the comparison explicitly. The dotted command is intentional; there is no `:diffclose` alias.
|
||||||
|
|
||||||
Binary data, missing final newlines, permission/type changes, symlink targets, and submodule pointers have explicit notices. Symlinks are not followed and submodule contents are not traversed. jj conflicts use jj's file-content projection and carry conflict notices; ordinary files remain readable. Identical-content moves are shown as renames; modified moves appear as an added and removed file.
|
Binary data, missing final newlines, permission/type changes, symlink targets, and submodule pointers have explicit notices. Symlinks are not followed and submodule contents are not traversed. jj conflicts use jj's file-content projection and carry conflict notices; ordinary files remain readable. Identical-content moves are shown as renames; modified moves appear as an added and removed file.
|
||||||
|
|
||||||
|
|||||||
+70
-18
@@ -690,21 +690,69 @@ impl DiffState {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn after_display_row(&self, source_row: usize) -> Option<usize> {
|
pub(crate) fn source_display_row(&self, focus: Focus, source_row: usize) -> Option<usize> {
|
||||||
self.display_rows.iter().position(|display| {
|
self.display_rows.iter().position(|display| {
|
||||||
matches!(*display, DisplayRow::Line(index) if self.rows[index].after == Some(source_row))
|
matches!(*display, DisplayRow::Line(index) if self.source_row(focus, index) == Some(source_row))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn after_rows(&self) -> impl DoubleEndedIterator<Item = usize> + '_ {
|
pub fn after_display_row(&self, source_row: usize) -> Option<usize> {
|
||||||
self.excerpt_ranges
|
self.source_display_row(Focus::After, source_row)
|
||||||
.iter()
|
|
||||||
.flat_map(|&(start, end)| self.rows[start..end].iter().filter_map(|row| row.after))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn clamp_after_row(&self, row: usize) -> Option<usize> {
|
fn source_row(&self, focus: Focus, index: usize) -> Option<usize> {
|
||||||
|
match focus {
|
||||||
|
Focus::Before => self.rows[index].before,
|
||||||
|
Focus::After => self.rows[index].after,
|
||||||
|
Focus::Files => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn source_rows(&self, focus: Focus) -> impl DoubleEndedIterator<Item = usize> + '_ {
|
||||||
|
self.excerpt_ranges.iter().flat_map(move |&(start, end)| {
|
||||||
|
(start..end).filter_map(move |index| self.source_row(focus, index))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn snapshot_lines(&self, focus: Focus) -> Vec<String> {
|
||||||
|
let file = &self.comparison.as_ref().expect("snapshot comparison").files[self.file];
|
||||||
|
let (source, eof, live) = match focus {
|
||||||
|
Focus::Before => (&file.before, file.before_eof, false),
|
||||||
|
Focus::After => (
|
||||||
|
&file.after,
|
||||||
|
file.after_eof,
|
||||||
|
self.live_files.contains(&self.file),
|
||||||
|
),
|
||||||
|
Focus::Files => unreachable!("snapshot belongs to a text pane"),
|
||||||
|
};
|
||||||
|
let mut lines = source.clone();
|
||||||
|
if !live && (eof || lines.is_empty()) {
|
||||||
|
lines.push(String::new());
|
||||||
|
}
|
||||||
|
lines
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn corresponding_row(&self, from: Focus, to: Focus, row: usize) -> Option<usize> {
|
||||||
|
let aligned = self.rows.iter().position(|aligned| match from {
|
||||||
|
Focus::Before => aligned.before == Some(row),
|
||||||
|
Focus::After => aligned.after == Some(row),
|
||||||
|
Focus::Files => false,
|
||||||
|
})?;
|
||||||
|
self.display_rows
|
||||||
|
.iter()
|
||||||
|
.filter_map(|display| match *display {
|
||||||
|
DisplayRow::Line(index) => self
|
||||||
|
.source_row(to, index)
|
||||||
|
.map(|row| (index.abs_diff(aligned), row)),
|
||||||
|
DisplayRow::Fold { .. } => None,
|
||||||
|
})
|
||||||
|
.min_by_key(|&(distance, row)| (distance, row))
|
||||||
|
.map(|(_, row)| row)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn clamp_source_row(&self, focus: Focus, row: usize) -> Option<usize> {
|
||||||
let mut previous = None;
|
let mut previous = None;
|
||||||
for visible in self.after_rows() {
|
for visible in self.source_rows(focus) {
|
||||||
if visible >= row {
|
if visible >= row {
|
||||||
return Some(match previous {
|
return Some(match previous {
|
||||||
Some(previous) if row - previous < visible - row => previous,
|
Some(previous) if row - previous < visible - row => previous,
|
||||||
@@ -1007,8 +1055,8 @@ impl DiffState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn expand_after_excerpt(&mut self, below: bool, source_row: usize) {
|
pub(crate) fn expand_source_excerpt(&mut self, focus: Focus, below: bool, source_row: usize) {
|
||||||
let Some(display_row) = self.after_display_row(source_row) else {
|
let Some(display_row) = self.source_display_row(focus, source_row) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
self.expand_excerpt_at(below, self.display_rows[display_row]);
|
self.expand_excerpt_at(below, self.display_rows[display_row]);
|
||||||
@@ -1983,17 +2031,21 @@ mod tests {
|
|||||||
assert_eq!(editor.diff().unwrap().focus, Focus::Before);
|
assert_eq!(editor.diff().unwrap().focus, Focus::Before);
|
||||||
editor.handle(Input::Down);
|
editor.handle(Input::Down);
|
||||||
editor.handle(Input::Right);
|
editor.handle(Input::Right);
|
||||||
assert_eq!(editor.diff().unwrap().top, 1);
|
assert_eq!(editor.cursor().row, 1);
|
||||||
assert_eq!(editor.diff().unwrap().left_before, 1);
|
assert_eq!(editor.cursor().column, 1);
|
||||||
|
editor.scroll_diff(Focus::Before, true);
|
||||||
|
editor.set_diff_viewport(id, 0, 1, 1, 0);
|
||||||
editor.handle(Input::Char('\t'));
|
editor.handle(Input::Char('\t'));
|
||||||
assert_eq!(editor.diff().unwrap().focus, Focus::After);
|
assert_eq!(editor.diff().unwrap().focus, Focus::After);
|
||||||
editor.handle(Input::Ctrl('j'));
|
editor.handle(Input::Down);
|
||||||
editor.handle(Input::Ctrl('l'));
|
editor.handle(Input::Right);
|
||||||
assert_eq!(editor.diff().unwrap().top, 2);
|
assert_eq!(editor.cursor().row, 2);
|
||||||
|
assert_eq!(editor.diff().unwrap().top, 1);
|
||||||
|
editor.set_diff_viewport(id, 0, 2, 1, 1);
|
||||||
assert_eq!(editor.diff().unwrap().left_after, 1);
|
assert_eq!(editor.diff().unwrap().left_after, 1);
|
||||||
assert_eq!(editor.diff().unwrap().left_before, 1);
|
assert_eq!(editor.diff().unwrap().left_before, 1);
|
||||||
editor.handle(Input::End);
|
editor.handle(Input::End);
|
||||||
assert_eq!(editor.diff().unwrap().top, 5);
|
assert_eq!(editor.diff().unwrap().top, 2);
|
||||||
editor.handle(Input::BackTab);
|
editor.handle(Input::BackTab);
|
||||||
assert_eq!(editor.diff().unwrap().focus, Focus::Before);
|
assert_eq!(editor.diff().unwrap().focus, Focus::Before);
|
||||||
editor.handle(Input::BackTab);
|
editor.handle(Input::BackTab);
|
||||||
@@ -2136,12 +2188,12 @@ mod tests {
|
|||||||
] {
|
] {
|
||||||
editor.handle(input);
|
editor.handle(input);
|
||||||
}
|
}
|
||||||
assert_eq!(editor.context().text, original);
|
|
||||||
assert_eq!(editor.carets(), carets);
|
|
||||||
editor.handle(Input::Escape);
|
editor.handle(Input::Escape);
|
||||||
editor.handle(Input::Escape);
|
editor.handle(Input::Escape);
|
||||||
assert!(editor.diff().is_some());
|
assert!(editor.diff().is_some());
|
||||||
editor.execute("diff.close");
|
editor.execute("diff.close");
|
||||||
|
assert_eq!(editor.context().text, original);
|
||||||
|
assert_eq!(editor.carets(), carets);
|
||||||
editor.handle(Input::Char('u'));
|
editor.handle(Input::Char('u'));
|
||||||
assert_eq!(editor.context().text, "original\\nsecond");
|
assert_eq!(editor.context().text, "original\\nsecond");
|
||||||
}
|
}
|
||||||
|
|||||||
+837
-199
File diff suppressed because it is too large
Load Diff
@@ -44,6 +44,8 @@ Buffers: :edit PATH, :buffers, :buffer N, :buffer_next, :buffer_previous, :buffe
|
|||||||
Markdown links: :follow_link (primary caret; local files only)
|
Markdown links: :follow_link (primary caret; local files only)
|
||||||
Diff: :diff (Normal); choose Before then After; Working checkout After edits live unsaved buffers
|
Diff: :diff (Normal); choose Before then After; Working checkout After edits live unsaved buffers
|
||||||
Before and historical/remote After stay read-only; Tab/Shift+Tab categories/Normal focus
|
Before and historical/remote After stay read-only; Tab/Shift+Tab categories/Normal focus
|
||||||
|
All text panes use native motions, selections and copy; read-only panes reject edits/saves
|
||||||
|
Switching Before/After preserves the aligned cursor row and display column
|
||||||
Type to search IDs/refs; navigation skips omitted lines without expanding excerpts
|
Type to search IDs/refs; navigation skips omitted lines without expanding excerpts
|
||||||
Normal [/] explicitly expands context; Insert brackets remain literal text
|
Normal [/] explicitly expands context; Insert brackets remain literal text
|
||||||
:diff.close exits explicitly; Esc cancels modes/prompts/filtering without closing the diff
|
:diff.close exits explicitly; Esc cancels modes/prompts/filtering without closing the diff
|
||||||
|
|||||||
+19
-11
@@ -263,10 +263,11 @@ impl EditorSession {
|
|||||||
comparison_id: u64,
|
comparison_id: u64,
|
||||||
file: usize,
|
file: usize,
|
||||||
top: usize,
|
top: usize,
|
||||||
|
left_before: usize,
|
||||||
left_after: usize,
|
left_after: usize,
|
||||||
) {
|
) {
|
||||||
self.editor
|
self.editor
|
||||||
.set_diff_viewport(comparison_id, file, top, left_after);
|
.set_diff_viewport(comparison_id, file, top, left_before, left_after);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn scroll_diff(&mut self, focus: Focus, down: bool) {
|
pub(crate) fn scroll_diff(&mut self, focus: Focus, down: bool) {
|
||||||
@@ -405,7 +406,7 @@ impl EditorSession {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let context = self.editor.context();
|
let context = self.editor.context();
|
||||||
let read_only = self.editor.diff().is_some() && !self.editor.diff_editing();
|
let read_only = self.editor.mutation_blocked();
|
||||||
let result = match self.plugins.get_mut(&invocation.plugin) {
|
let result = match self.plugins.get_mut(&invocation.plugin) {
|
||||||
Some(plugin) if plugin.is_async() => {
|
Some(plugin) if plugin.is_async() => {
|
||||||
let requests = self.requests.entry(invocation.plugin.clone()).or_default();
|
let requests = self.requests.entry(invocation.plugin.clone()).or_default();
|
||||||
@@ -483,7 +484,7 @@ impl EditorSession {
|
|||||||
}
|
}
|
||||||
let mut events = Vec::new();
|
let mut events = Vec::new();
|
||||||
if !self.editor.should_quit() {
|
if !self.editor.should_quit() {
|
||||||
for document in self.editor.documents() {
|
for document in self.editor.interaction_documents() {
|
||||||
let path = document.path.map(Path::to_string_lossy);
|
let path = document.path.map(Path::to_string_lossy);
|
||||||
let previous = self.documents.get(&document.id);
|
let previous = self.documents.get(&document.id);
|
||||||
let renamed = previous
|
let renamed = previous
|
||||||
@@ -576,7 +577,7 @@ impl EditorSession {
|
|||||||
|
|
||||||
fn source_is_current(&self, source: InvocationSource) -> bool {
|
fn source_is_current(&self, source: InvocationSource) -> bool {
|
||||||
self.editor
|
self.editor
|
||||||
.documents()
|
.interaction_documents()
|
||||||
.any(|document| document.id == source.buffer_id && document.revision == source.revision)
|
.any(|document| document.id == source.buffer_id && document.revision == source.revision)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -586,7 +587,7 @@ impl EditorSession {
|
|||||||
request_id: Option<u64>,
|
request_id: Option<u64>,
|
||||||
source: InvocationSource,
|
source: InvocationSource,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
if self.editor.diff().is_some() && !self.editor.diff_editing() {
|
if self.editor.diff().is_some() && !self.editor.diff_interacting() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
self.editor.buffer_id() == source.buffer_id
|
self.editor.buffer_id() == source.buffer_id
|
||||||
@@ -622,6 +623,7 @@ impl EditorSession {
|
|||||||
|
|
||||||
fn completion_is_current(&self, request_id: Option<u64>, source: InvocationSource) -> bool {
|
fn completion_is_current(&self, request_id: Option<u64>, source: InvocationSource) -> bool {
|
||||||
self.pending_completion
|
self.pending_completion
|
||||||
|
&& !self.editor.mutation_blocked()
|
||||||
&& self.request_is_current("core", request_id, source)
|
&& self.request_is_current("core", request_id, source)
|
||||||
&& self.editor.mode() == Mode::Insert
|
&& self.editor.mode() == Mode::Insert
|
||||||
&& self.editor.palette().is_none()
|
&& self.editor.palette().is_none()
|
||||||
@@ -855,7 +857,7 @@ impl EditorSession {
|
|||||||
}
|
}
|
||||||
return Ok(changed);
|
return Ok(changed);
|
||||||
}
|
}
|
||||||
if self.editor.diff().is_some() && !self.editor.diff_editing() {
|
if self.editor.mutation_blocked() {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
let changed = !effects.is_empty();
|
let changed = !effects.is_empty();
|
||||||
@@ -3425,29 +3427,35 @@ end
|
|||||||
.apply_response("fixture", 2, vec![Effect::Insert("stale".into())])
|
.apply_response("fixture", 2, vec![Effect::Insert("stale".into())])
|
||||||
.unwrap()
|
.unwrap()
|
||||||
);
|
);
|
||||||
let current = InvocationSource {
|
session.editor.scroll_diff(Focus::Before, false);
|
||||||
|
let readonly = InvocationSource {
|
||||||
buffer_id: session.editor.buffer_id(),
|
buffer_id: session.editor.buffer_id(),
|
||||||
revision: session.editor.text_revision(),
|
revision: session.editor.text_revision(),
|
||||||
};
|
};
|
||||||
session.editor.scroll_diff(Focus::Before, false);
|
|
||||||
session
|
session
|
||||||
.requests
|
.requests
|
||||||
.get_mut("fixture")
|
.get_mut("fixture")
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.pending
|
.pending
|
||||||
.insert(3, current);
|
.insert(3, readonly);
|
||||||
assert!(
|
assert!(
|
||||||
!session
|
!session
|
||||||
.apply_response("fixture", 3, vec![Effect::Insert("readonly".into())])
|
.apply_response("fixture", 3, vec![Effect::Insert("readonly".into())])
|
||||||
.unwrap()
|
.unwrap()
|
||||||
);
|
);
|
||||||
assert_eq!(session.editor.lines(), ["acceptedbase"]);
|
assert_eq!(session.editor.lines(), ["base"]);
|
||||||
|
assert!(
|
||||||
|
session
|
||||||
|
.editor
|
||||||
|
.documents()
|
||||||
|
.any(|document| document.lines == ["acceptedbase"])
|
||||||
|
);
|
||||||
session
|
session
|
||||||
.requests
|
.requests
|
||||||
.get_mut("fixture")
|
.get_mut("fixture")
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.pending
|
.pending
|
||||||
.insert(4, current);
|
.insert(4, readonly);
|
||||||
assert!(
|
assert!(
|
||||||
session
|
session
|
||||||
.apply_response("fixture", 4, vec![Effect::Message("allowed".into())])
|
.apply_response("fixture", 4, vec![Effect::Message("allowed".into())])
|
||||||
|
|||||||
+324
-37
@@ -174,13 +174,13 @@ impl BufferViewports {
|
|||||||
|
|
||||||
fn draw_session(&mut self, frame: &mut Frame, session: &mut EditorSession) {
|
fn draw_session(&mut self, frame: &mut Frame, session: &mut EditorSession) {
|
||||||
self.draw(frame, session.editor());
|
self.draw(frame, session.editor());
|
||||||
if let Some((comparison_id, file, top, left_after)) = self
|
if let Some((comparison_id, file, top, left_before, left_after)) = self
|
||||||
.panels
|
.panels
|
||||||
.diff
|
.diff
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|geometry| geometry.viewport)
|
.and_then(|geometry| geometry.viewport)
|
||||||
{
|
{
|
||||||
session.set_diff_viewport(comparison_id, file, top, left_after);
|
session.set_diff_viewport(comparison_id, file, top, left_before, left_after);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -720,7 +720,7 @@ fn draw(
|
|||||||
|
|
||||||
struct DiffGeometry {
|
struct DiffGeometry {
|
||||||
regions: [(Rect, Focus); 3],
|
regions: [(Rect, Focus); 3],
|
||||||
viewport: Option<(u64, usize, usize, usize)>,
|
viewport: Option<(u64, usize, usize, usize, usize)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn diff_panel(frame: &mut Frame, area: Rect, title: &str, focused: bool) -> Rect {
|
fn diff_panel(frame: &mut Frame, area: Rect, title: &str, focused: bool) -> Rect {
|
||||||
@@ -915,6 +915,7 @@ fn paint_diff_editor(
|
|||||||
editor: &Editor,
|
editor: &Editor,
|
||||||
diff: &DiffState,
|
diff: &DiffState,
|
||||||
area: Rect,
|
area: Rect,
|
||||||
|
focus: Focus,
|
||||||
viewport: (usize, usize),
|
viewport: (usize, usize),
|
||||||
) -> Option<Position> {
|
) -> Option<Position> {
|
||||||
let (top, left) = viewport;
|
let (top, left) = viewport;
|
||||||
@@ -931,12 +932,16 @@ fn paint_diff_editor(
|
|||||||
let DisplayRow::Line(aligned) = *row else {
|
let DisplayRow::Line(aligned) = *row else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let Some(source) = diff.rows[aligned].after else {
|
let Some(source) = (match focus {
|
||||||
|
Focus::Before => diff.rows[aligned].before,
|
||||||
|
Focus::After => diff.rows[aligned].after,
|
||||||
|
Focus::Files => None,
|
||||||
|
}) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let line_area = Rect::new(area.x, area.y + (display - top) as u16, area.width, 1);
|
let line_area = Rect::new(area.x, area.y + (display - top) as u16, area.width, 1);
|
||||||
paint_editor_line(frame, editor, source, line_area, left, &carets, &selections);
|
paint_editor_line(frame, editor, source, line_area, left, &carets, &selections);
|
||||||
if source == editor.cursor().row && editor.diff_editing() {
|
if source == editor.cursor().row && editor.diff_interacting() {
|
||||||
let column = display_column(&editor.lines()[source], editor.cursor().column);
|
let column = display_column(&editor.lines()[source], editor.cursor().column);
|
||||||
if column >= left && column < left.saturating_add(usize::from(area.width)) {
|
if column >= left && column < left.saturating_add(usize::from(area.width)) {
|
||||||
cursor = Some(Position::new(area.x + (column - left) as u16, line_area.y));
|
cursor = Some(Position::new(area.x + (column - left) as u16, line_area.y));
|
||||||
@@ -1243,16 +1248,27 @@ fn draw_diff(
|
|||||||
display_text(before),
|
display_text(before),
|
||||||
display_text(after),
|
display_text(after),
|
||||||
diff.focus,
|
diff.focus,
|
||||||
editor.filter(|editor| editor.diff_editable()).map_or_else(
|
editor
|
||||||
|| "read-only".into(),
|
.filter(|editor| editor.diff_interacting())
|
||||||
|editor| format!(
|
.map_or_else(
|
||||||
"After editable · {}{} · {}:{}",
|
|| "read-only".into(),
|
||||||
editor.mode(),
|
|editor| format!(
|
||||||
if editor.dirty() { " [+]" } else { "" },
|
"{} · {}{} · {}:{}",
|
||||||
editor.cursor().row + 1,
|
if editor.diff_editing() {
|
||||||
editor.cursor().column + 1,
|
"editable"
|
||||||
),
|
} else {
|
||||||
)
|
"read-only"
|
||||||
|
},
|
||||||
|
if editor.visual_linewise() {
|
||||||
|
"VISUAL LINE".into()
|
||||||
|
} else {
|
||||||
|
editor.mode().to_string()
|
||||||
|
},
|
||||||
|
if editor.dirty() { " [+]" } else { "" },
|
||||||
|
editor.cursor().row + 1,
|
||||||
|
editor.cursor().column + 1,
|
||||||
|
),
|
||||||
|
)
|
||||||
))
|
))
|
||||||
.style(Style::default().fg(Color::Black).bg(Color::Cyan)),
|
.style(Style::default().fg(Color::Black).bg(Color::Cyan)),
|
||||||
header,
|
header,
|
||||||
@@ -1289,25 +1305,42 @@ fn draw_diff(
|
|||||||
};
|
};
|
||||||
let mut document_caret = None;
|
let mut document_caret = None;
|
||||||
let mut top = diff.top;
|
let mut top = diff.top;
|
||||||
|
let mut before_left = diff.left_before;
|
||||||
let mut after_left = diff.left_after;
|
let mut after_left = diff.left_after;
|
||||||
if let Some(editor) = editor.filter(|editor| {
|
if let Some(editor) = editor.filter(|editor| editor.diff_interacting() && diff.follow_caret)
|
||||||
editor.diff_editing() && diff.follow_caret && after_area != Rect::default()
|
&& let Some(focus) = editor.diff_buffer_focus()
|
||||||
}) {
|
{
|
||||||
let height = usize::from(after_area.height.saturating_sub(2));
|
let (pane, left) = match focus {
|
||||||
if let Some(row) = diff.after_display_row(editor.cursor().row) {
|
Focus::Before => (before_area, &mut before_left),
|
||||||
|
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));
|
||||||
|
if let Some(row) = diff.source_display_row(focus, editor.cursor().row) {
|
||||||
if row < top {
|
if row < top {
|
||||||
top = row;
|
top = row;
|
||||||
} else if height > 0 && row >= top.saturating_add(height) {
|
} else if height > 0 && row >= top.saturating_add(height) {
|
||||||
top = row + 1 - height;
|
top = row + 1 - height;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let digits = editor.lines().len().max(1).ilog10() as usize + 1;
|
let source_len = selected.map_or(0, |file| match focus {
|
||||||
let width = usize::from(after_area.width.saturating_sub(2)).saturating_sub(digits + 2);
|
Focus::Before => file.before.len(),
|
||||||
|
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 column = display_column(&editor.lines()[editor.cursor().row], editor.cursor().column);
|
let column = display_column(&editor.lines()[editor.cursor().row], editor.cursor().column);
|
||||||
if column < after_left {
|
if column < *left {
|
||||||
after_left = column;
|
*left = column;
|
||||||
} else if width > 0 && column >= after_left.saturating_add(width) {
|
} else if width > 0 && column >= left.saturating_add(width) {
|
||||||
after_left = column + 1 - width;
|
*left = column + 1 - width;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(picker) = &diff.files
|
if let Some(picker) = &diff.files
|
||||||
@@ -1329,15 +1362,28 @@ fn draw_diff(
|
|||||||
diff_panel(frame, before_area, "", diff.focus == Focus::Before);
|
diff_panel(frame, before_area, "", diff.focus == Focus::Before);
|
||||||
} else {
|
} else {
|
||||||
if before_area != Rect::default() {
|
if before_area != Rect::default() {
|
||||||
draw_diff_pane(
|
let text_area = draw_diff_pane(
|
||||||
frame,
|
frame,
|
||||||
diff,
|
diff,
|
||||||
file,
|
file,
|
||||||
before_area,
|
before_area,
|
||||||
true,
|
true,
|
||||||
&syntax.before,
|
&syntax.before,
|
||||||
(top, diff.left_before),
|
(top, before_left),
|
||||||
);
|
);
|
||||||
|
if let Some(editor) =
|
||||||
|
editor.filter(|editor| editor.diff_buffer_focus() == Some(Focus::Before))
|
||||||
|
{
|
||||||
|
document_caret = paint_diff_editor(
|
||||||
|
frame,
|
||||||
|
editor,
|
||||||
|
diff,
|
||||||
|
text_area,
|
||||||
|
Focus::Before,
|
||||||
|
(top, before_left),
|
||||||
|
)
|
||||||
|
.map(|position| (text_area, position));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if after_area != Rect::default() {
|
if after_area != Rect::default() {
|
||||||
let text_area = draw_diff_pane(
|
let text_area = draw_diff_pane(
|
||||||
@@ -1349,10 +1395,18 @@ fn draw_diff(
|
|||||||
&syntax.after,
|
&syntax.after,
|
||||||
(top, after_left),
|
(top, after_left),
|
||||||
);
|
);
|
||||||
if let Some(editor) = editor.filter(|editor| editor.diff_editable()) {
|
if let Some(editor) =
|
||||||
document_caret =
|
editor.filter(|editor| editor.diff_buffer_focus() == Some(Focus::After))
|
||||||
paint_diff_editor(frame, editor, diff, text_area, (top, after_left))
|
{
|
||||||
.map(|position| (text_area, position));
|
document_caret = paint_diff_editor(
|
||||||
|
frame,
|
||||||
|
editor,
|
||||||
|
diff,
|
||||||
|
text_area,
|
||||||
|
Focus::After,
|
||||||
|
(top, after_left),
|
||||||
|
)
|
||||||
|
.map(|position| (text_area, position));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1368,7 +1422,7 @@ fn draw_diff(
|
|||||||
}
|
}
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
Paragraph::new(
|
Paragraph::new(
|
||||||
"File-picker/explorer bindings · Tab focus · ↑/↓ scroll/tree · [/] expand above/below in Normal · :diff.close",
|
"Native buffer bindings · Tab focus · [/] expand above/below in Normal · :diff.close",
|
||||||
)
|
)
|
||||||
.style(Style::default().fg(Color::DarkGray)),
|
.style(Style::default().fg(Color::DarkGray)),
|
||||||
help,
|
help,
|
||||||
@@ -1381,7 +1435,7 @@ fn draw_diff(
|
|||||||
],
|
],
|
||||||
viewport: diff
|
viewport: diff
|
||||||
.comparison_id
|
.comparison_id
|
||||||
.map(|id| (id, diff.file, top, after_left)),
|
.map(|id| (id, diff.file, top, before_left, after_left)),
|
||||||
});
|
});
|
||||||
if let Some(picker) = &diff.files
|
if let Some(picker) = &diff.files
|
||||||
&& diff.files_visible
|
&& diff.files_visible
|
||||||
@@ -2732,7 +2786,8 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert_eq!(present.x, 0);
|
assert_eq!(present.x, 0);
|
||||||
assert_eq!(present.width, 160);
|
assert_eq!(present.width, 160);
|
||||||
assert_eq!(geometry.viewport.unwrap().3, 2);
|
assert_eq!(geometry.viewport.unwrap().3, 0);
|
||||||
|
assert_eq!(geometry.viewport.unwrap().4, 2);
|
||||||
assert_eq!(geometry.regions[if added { 2 } else { 1 }].1, diff.focus);
|
assert_eq!(geometry.regions[if added { 2 } else { 1 }].1, diff.focus);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
terminal.backend().buffer()[(present.x, present.y)].fg,
|
terminal.backend().buffer()[(present.x, present.y)].fg,
|
||||||
@@ -3514,7 +3569,6 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(screen_text(&terminal).contains("deleted-lines.txt"));
|
assert!(screen_text(&terminal).contains("deleted-lines.txt"));
|
||||||
assert!(viewports.document.geometry.is_none());
|
assert!(viewports.document.geometry.is_none());
|
||||||
assert_eq!(editor.lines(), original);
|
|
||||||
editor.handle(Input::Escape);
|
editor.handle(Input::Escape);
|
||||||
assert!(editor.diff().is_some());
|
assert!(editor.diff().is_some());
|
||||||
editor.execute("diff.close");
|
editor.execute("diff.close");
|
||||||
@@ -3523,9 +3577,241 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(screen_text(&terminal).contains("original"));
|
assert!(screen_text(&terminal).contains("original"));
|
||||||
assert!(viewports.document.geometry.is_some());
|
assert!(viewports.document.geometry.is_some());
|
||||||
|
assert_eq!(editor.lines(), original);
|
||||||
assert_eq!(editor.cursor(), cursor);
|
assert_eq!(editor.cursor(), cursor);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn readonly_diff_editor(file: ChangedFile) -> Editor {
|
||||||
|
let mut editor = Editor::open(None, vec![]).unwrap();
|
||||||
|
editor.set_clipboard_mode("separate").unwrap();
|
||||||
|
editor.execute("diff");
|
||||||
|
let (id, _) = editor.take_diff_request().unwrap();
|
||||||
|
editor.apply_diff_result(id, Ok(crate::diff::Outcome::Catalog(diff_catalog())));
|
||||||
|
editor.handle(Input::Down);
|
||||||
|
editor.handle(Input::Enter);
|
||||||
|
editor.handle(Input::Down);
|
||||||
|
editor.handle(Input::Enter);
|
||||||
|
let (id, _) = editor.take_diff_request().unwrap();
|
||||||
|
editor.apply_diff_result(
|
||||||
|
id,
|
||||||
|
Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison {
|
||||||
|
root: std::path::PathBuf::new(),
|
||||||
|
editable_paths: Default::default(),
|
||||||
|
before: "baseline".into(),
|
||||||
|
after: "historic".into(),
|
||||||
|
files: vec![file],
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
editor
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_native_diff(
|
||||||
|
terminal: &mut Terminal<ratatui::backend::TestBackend>,
|
||||||
|
views: &mut BufferViewports,
|
||||||
|
editor: &mut Editor,
|
||||||
|
) -> Position {
|
||||||
|
terminal.draw(|frame| views.draw(frame, editor)).unwrap();
|
||||||
|
let (id, file, top, before, after) = views.panels.diff.as_ref().unwrap().viewport.unwrap();
|
||||||
|
editor.set_diff_viewport(id, file, top, before, after);
|
||||||
|
terminal.get_cursor_position().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn readonly_diff_panes_render_native_caret_visual_search_and_paired_focus() {
|
||||||
|
let mut editor = readonly_diff_editor(syntax_diff_file(
|
||||||
|
"history.rs",
|
||||||
|
"fn old() {}\nlet value = \"old\";",
|
||||||
|
"fn new() {}\nlet value = \"new\";",
|
||||||
|
));
|
||||||
|
editor.handle(Input::Char('\t'));
|
||||||
|
assert!(editor.diff_interacting());
|
||||||
|
assert!(!editor.diff_editing());
|
||||||
|
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 14)).unwrap();
|
||||||
|
let mut views = BufferViewports::default();
|
||||||
|
let caret = render_native_diff(&mut terminal, &mut views, &mut editor);
|
||||||
|
let before = views.panels.diff.as_ref().unwrap().regions[1].0;
|
||||||
|
assert!(before.contains(caret));
|
||||||
|
assert_eq!(
|
||||||
|
terminal.backend().buffer()[(caret.x, caret.y)].symbol(),
|
||||||
|
"f"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
terminal.backend().buffer()[(caret.x, caret.y)].fg,
|
||||||
|
Color::Magenta
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
terminal.backend().buffer()[(caret.x, caret.y)].bg,
|
||||||
|
Color::Rgb(64, 24, 24)
|
||||||
|
);
|
||||||
|
editor.handle(Input::Char('v'));
|
||||||
|
editor.handle(Input::Right);
|
||||||
|
render_native_diff(&mut terminal, &mut views, &mut editor);
|
||||||
|
assert_eq!(
|
||||||
|
terminal.backend().buffer()[(caret.x, caret.y)].bg,
|
||||||
|
Color::Blue
|
||||||
|
);
|
||||||
|
editor.handle(Input::Escape);
|
||||||
|
editor.handle(Input::Down);
|
||||||
|
editor.handle(Input::Home);
|
||||||
|
editor.handle(Input::Char('V'));
|
||||||
|
render_native_diff(&mut terminal, &mut views, &mut editor);
|
||||||
|
assert_eq!(
|
||||||
|
terminal.backend().buffer()[(caret.x, caret.y + 1)].bg,
|
||||||
|
Color::Blue
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
terminal.backend().buffer()[(caret.x + 16, caret.y + 1)].bg,
|
||||||
|
Color::Blue
|
||||||
|
);
|
||||||
|
editor.handle(Input::Escape);
|
||||||
|
editor.handle(Input::Char('/'));
|
||||||
|
for ch in "value".chars() {
|
||||||
|
editor.handle(Input::Char(ch));
|
||||||
|
}
|
||||||
|
render_native_diff(&mut terminal, &mut views, &mut editor);
|
||||||
|
assert_eq!(terminal.get_cursor_position().unwrap().y, 13);
|
||||||
|
editor.handle(Input::Enter);
|
||||||
|
let searched = render_native_diff(&mut terminal, &mut views, &mut editor);
|
||||||
|
assert!(before.contains(searched));
|
||||||
|
assert_eq!(
|
||||||
|
terminal.backend().buffer()[(searched.x, searched.y)].bg,
|
||||||
|
Color::Yellow
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
terminal.backend().buffer()[(searched.x, searched.y)].fg,
|
||||||
|
Color::Black
|
||||||
|
);
|
||||||
|
let before_row = editor
|
||||||
|
.diff()
|
||||||
|
.unwrap()
|
||||||
|
.source_display_row(Focus::Before, editor.cursor().row)
|
||||||
|
.unwrap();
|
||||||
|
let column = display_column(&editor.lines()[editor.cursor().row], editor.cursor().column);
|
||||||
|
editor.handle(Input::Char('\t'));
|
||||||
|
assert!(editor.diff_interacting());
|
||||||
|
assert!(!editor.diff_editing());
|
||||||
|
let switched = render_native_diff(&mut terminal, &mut views, &mut editor);
|
||||||
|
let after = views.panels.diff.as_ref().unwrap().regions[2].0;
|
||||||
|
assert!(after.contains(switched));
|
||||||
|
assert_eq!(switched.y, searched.y);
|
||||||
|
assert_eq!(switched.x - after.x, searched.x - before.x);
|
||||||
|
assert_eq!(
|
||||||
|
editor
|
||||||
|
.diff()
|
||||||
|
.unwrap()
|
||||||
|
.source_display_row(Focus::After, editor.cursor().row),
|
||||||
|
Some(before_row)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
display_column(&editor.lines()[editor.cursor().row], editor.cursor().column),
|
||||||
|
column
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
terminal.backend().buffer()[(switched.x, switched.y)].bg,
|
||||||
|
Color::Rgb(24, 48, 32)
|
||||||
|
);
|
||||||
|
editor.handle(Input::Right);
|
||||||
|
let moved = render_native_diff(&mut terminal, &mut views, &mut editor);
|
||||||
|
editor.handle(Input::Char('\t'));
|
||||||
|
editor.handle(Input::Char('\t'));
|
||||||
|
assert_eq!(editor.diff().unwrap().focus, Focus::Before);
|
||||||
|
let returned = render_native_diff(&mut terminal, &mut views, &mut editor);
|
||||||
|
assert!(before.contains(returned));
|
||||||
|
assert_eq!(returned.y, moved.y);
|
||||||
|
assert_eq!(returned.x - before.x, moved.x - after.x);
|
||||||
|
assert_eq!(returned.x, searched.x + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn readonly_diff_viewport_retains_both_offsets_on_direction_and_focus_reversal() {
|
||||||
|
let before = (0..80)
|
||||||
|
.map(|row| format!("old row {row:03} {}", "x".repeat(100)))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
let after = before.replace("old row", "new row");
|
||||||
|
let mut editor = readonly_diff_editor(syntax_diff_file("history.txt", &before, &after));
|
||||||
|
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 18)).unwrap();
|
||||||
|
let mut views = BufferViewports::default();
|
||||||
|
let mut retained_before_left = 0;
|
||||||
|
for focus in [Focus::Before, Focus::After] {
|
||||||
|
editor.handle(Input::Char('\t'));
|
||||||
|
assert_eq!(editor.diff().unwrap().focus, focus);
|
||||||
|
assert!(editor.diff_interacting());
|
||||||
|
assert!(!editor.diff_editing());
|
||||||
|
render_native_diff(&mut terminal, &mut views, &mut editor);
|
||||||
|
for _ in 0..40 {
|
||||||
|
editor.handle(Input::Down);
|
||||||
|
render_native_diff(&mut terminal, &mut views, &mut editor);
|
||||||
|
}
|
||||||
|
let top = editor.diff().unwrap().top;
|
||||||
|
assert!(top > 0);
|
||||||
|
let bottom = render_native_diff(&mut terminal, &mut views, &mut editor);
|
||||||
|
editor.handle(Input::Up);
|
||||||
|
let reversed = render_native_diff(&mut terminal, &mut views, &mut editor);
|
||||||
|
assert_eq!(editor.diff().unwrap().top, top);
|
||||||
|
assert_eq!(reversed.y + 1, bottom.y);
|
||||||
|
while editor
|
||||||
|
.diff()
|
||||||
|
.unwrap()
|
||||||
|
.source_display_row(focus, editor.cursor().row)
|
||||||
|
.unwrap()
|
||||||
|
> top
|
||||||
|
{
|
||||||
|
editor.handle(Input::Up);
|
||||||
|
render_native_diff(&mut terminal, &mut views, &mut editor);
|
||||||
|
assert_eq!(editor.diff().unwrap().top, top);
|
||||||
|
}
|
||||||
|
editor.handle(Input::Up);
|
||||||
|
render_native_diff(&mut terminal, &mut views, &mut editor);
|
||||||
|
assert!(editor.diff().unwrap().top < top);
|
||||||
|
for _ in 0..80 {
|
||||||
|
editor.handle(Input::Right);
|
||||||
|
render_native_diff(&mut terminal, &mut views, &mut editor);
|
||||||
|
}
|
||||||
|
let left = if focus == Focus::Before {
|
||||||
|
editor.diff().unwrap().left_before
|
||||||
|
} else {
|
||||||
|
editor.diff().unwrap().left_after
|
||||||
|
};
|
||||||
|
assert!(left > 0);
|
||||||
|
let right = render_native_diff(&mut terminal, &mut views, &mut editor);
|
||||||
|
editor.handle(Input::Left);
|
||||||
|
let reversed = render_native_diff(&mut terminal, &mut views, &mut editor);
|
||||||
|
let offsets = views.panels.diff.as_ref().unwrap().viewport.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
if focus == Focus::Before {
|
||||||
|
offsets.3
|
||||||
|
} else {
|
||||||
|
offsets.4
|
||||||
|
},
|
||||||
|
left
|
||||||
|
);
|
||||||
|
assert_eq!(reversed.x + 1, right.x);
|
||||||
|
while editor.cursor().column > left {
|
||||||
|
editor.handle(Input::Left);
|
||||||
|
render_native_diff(&mut terminal, &mut views, &mut editor);
|
||||||
|
let diff = editor.diff().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
if focus == Focus::Before {
|
||||||
|
diff.left_before
|
||||||
|
} else {
|
||||||
|
diff.left_after
|
||||||
|
},
|
||||||
|
left
|
||||||
|
);
|
||||||
|
}
|
||||||
|
editor.handle(Input::Left);
|
||||||
|
render_native_diff(&mut terminal, &mut views, &mut editor);
|
||||||
|
if focus == Focus::Before {
|
||||||
|
retained_before_left = editor.diff().unwrap().left_before;
|
||||||
|
assert!(retained_before_left < left);
|
||||||
|
} else {
|
||||||
|
assert!(editor.diff().unwrap().left_after < left);
|
||||||
|
assert_eq!(editor.diff().unwrap().left_before, retained_before_left);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn editable_after_renders_live_syntax_caret_visual_selection_and_command_prompt() {
|
fn editable_after_renders_live_syntax_caret_visual_selection_and_command_prompt() {
|
||||||
let directory = Directory::new();
|
let directory = Directory::new();
|
||||||
@@ -3807,9 +4093,10 @@ mod tests {
|
|||||||
assert_eq!(diff.files.as_ref().unwrap().selected, selected);
|
assert_eq!(diff.files.as_ref().unwrap().selected, selected);
|
||||||
assert!(!diff.files.as_ref().unwrap().focused);
|
assert!(!diff.files.as_ref().unwrap().focused);
|
||||||
assert!(viewports.panels.files.is_none());
|
assert!(viewports.panels.files.is_none());
|
||||||
|
assert!(viewports.document.geometry.is_none());
|
||||||
|
session.execute("diff.close");
|
||||||
assert_eq!(session.editor().lines(), original);
|
assert_eq!(session.editor().lines(), original);
|
||||||
assert_eq!(session.editor().cursor(), cursor);
|
assert_eq!(session.editor().cursor(), cursor);
|
||||||
assert!(viewports.document.geometry.is_none());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+63
-2
@@ -143,8 +143,7 @@ fn typed_diff_edits_unsaved_working_after_and_preserves_undo_on_explicit_close()
|
|||||||
}
|
}
|
||||||
session.handle(Input::Escape);
|
session.handle(Input::Escape);
|
||||||
assert!(session.editor().diff().is_some());
|
assert!(session.editor().diff().is_some());
|
||||||
assert_eq!(session.editor().context().text, "uafter\n");
|
assert_eq!(session.editor().context().text, "before\n");
|
||||||
assert_eq!(session.editor().cursor(), cursor);
|
|
||||||
assert_eq!(fs::read_to_string(&source).unwrap(), "after\n");
|
assert_eq!(fs::read_to_string(&source).unwrap(), "after\n");
|
||||||
|
|
||||||
session.handle(Input::Char('\t'));
|
session.handle(Input::Char('\t'));
|
||||||
@@ -152,6 +151,8 @@ fn typed_diff_edits_unsaved_working_after_and_preserves_undo_on_explicit_close()
|
|||||||
session.editor().diff().unwrap().focus,
|
session.editor().diff().unwrap().focus,
|
||||||
vamp::diff::Focus::After
|
vamp::diff::Focus::After
|
||||||
);
|
);
|
||||||
|
assert_eq!(session.editor().context().text, "uafter\n");
|
||||||
|
assert_eq!(session.editor().cursor(), cursor);
|
||||||
for input in [Input::Char('i'), Input::Char('x'), Input::Escape] {
|
for input in [Input::Char('i'), Input::Char('x'), Input::Escape] {
|
||||||
session.handle(input);
|
session.handle(input);
|
||||||
}
|
}
|
||||||
@@ -320,3 +321,63 @@ fn editable_diff_navigation_skips_omitted_lines_without_expanding_excerpts() {
|
|||||||
type_query(&mut session, "]");
|
type_query(&mut session, "]");
|
||||||
assert_eq!(shown(&session), [16, 17, 18, 19, 20, 21, 22, 78, 79, 80]);
|
assert_eq!(shown(&session), [16, 17, 18, 19, 20, 21, 22, 78, 79, 80]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn readonly_jump_history_restores_the_comparison_file_and_source_cursor() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
fixture.git(&["init", "-q"]);
|
||||||
|
let root = fixture.0.join("repo");
|
||||||
|
for name in ["first", "second"] {
|
||||||
|
let mut lines = (1..=40)
|
||||||
|
.map(|row| format!("// {name}_stable_{row:03}"))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
lines[19] = format!("// {name}_before");
|
||||||
|
fs::write(root.join(format!("{name}.rs")), lines.join("\n") + "\n").unwrap();
|
||||||
|
}
|
||||||
|
fixture.git(&["add", "."]);
|
||||||
|
fixture.git(&["commit", "-qm", "base"]);
|
||||||
|
for name in ["first", "second"] {
|
||||||
|
let path = root.join(format!("{name}.rs"));
|
||||||
|
let text = fs::read_to_string(&path).unwrap();
|
||||||
|
fs::write(path, text.replace("_before", "_after")).unwrap();
|
||||||
|
}
|
||||||
|
let mut session = EditorSession::open(Some(root.join("first.rs")), vec![], None).unwrap();
|
||||||
|
session.execute("diff");
|
||||||
|
wait_until(&mut session, |session| {
|
||||||
|
!session.editor().diff().unwrap().loading
|
||||||
|
});
|
||||||
|
for input in [Input::Down, Input::Enter, Input::Enter] {
|
||||||
|
session.handle(input);
|
||||||
|
}
|
||||||
|
wait_until(&mut session, |session| {
|
||||||
|
session.editor().diff().unwrap().phase == Phase::View
|
||||||
|
});
|
||||||
|
session.handle(Input::Char('\t'));
|
||||||
|
let first_origin = session.editor().cursor();
|
||||||
|
type_query(&mut session, "/first_before");
|
||||||
|
session.handle(Input::Enter);
|
||||||
|
for input in [
|
||||||
|
Input::Char(' '),
|
||||||
|
Input::Char('e'),
|
||||||
|
Input::Home,
|
||||||
|
Input::Down,
|
||||||
|
Input::Enter,
|
||||||
|
] {
|
||||||
|
session.handle(input);
|
||||||
|
}
|
||||||
|
let second_origin = session.editor().cursor();
|
||||||
|
type_query(&mut session, "/second_before");
|
||||||
|
session.handle(Input::Enter);
|
||||||
|
session.handle(Input::Ctrl('o'));
|
||||||
|
assert_eq!(session.editor().cursor(), second_origin);
|
||||||
|
session.handle(Input::Ctrl('o'));
|
||||||
|
assert_eq!(session.editor().diff().unwrap().file, 0);
|
||||||
|
assert_eq!(session.editor().cursor(), first_origin);
|
||||||
|
assert!(session.editor().context().text.contains("first_before"));
|
||||||
|
assert!(!session.editor().context().text.contains("second_before"));
|
||||||
|
session.execute("diff.close");
|
||||||
|
assert_eq!(
|
||||||
|
session.editor().context().text,
|
||||||
|
fs::read_to_string(root.join("first.rs")).unwrap()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user