diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..deb16bd --- /dev/null +++ b/TODO.md @@ -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. diff --git a/docs/editing.md b/docs/editing.md index 4905b1e..6d43b9b 100644 --- a/docs/editing.md +++ b/docs/editing.md @@ -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. -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. @@ -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. -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). -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. -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. diff --git a/src/diff.rs b/src/diff.rs index 9ee776c..97f7ae0 100644 --- a/src/diff.rs +++ b/src/diff.rs @@ -690,21 +690,69 @@ impl DiffState { true } - pub fn after_display_row(&self, source_row: usize) -> Option { + pub(crate) fn source_display_row(&self, focus: Focus, source_row: usize) -> Option { 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 + '_ { - self.excerpt_ranges - .iter() - .flat_map(|&(start, end)| self.rows[start..end].iter().filter_map(|row| row.after)) + pub fn after_display_row(&self, source_row: usize) -> Option { + self.source_display_row(Focus::After, source_row) } - pub(crate) fn clamp_after_row(&self, row: usize) -> Option { + fn source_row(&self, focus: Focus, index: usize) -> Option { + 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 + '_ { + 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 { + 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 { + 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 { let mut previous = None; - for visible in self.after_rows() { + for visible in self.source_rows(focus) { if visible >= row { return Some(match 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) { - let Some(display_row) = self.after_display_row(source_row) else { + pub(crate) fn expand_source_excerpt(&mut self, focus: Focus, below: bool, source_row: usize) { + let Some(display_row) = self.source_display_row(focus, source_row) else { return; }; self.expand_excerpt_at(below, self.display_rows[display_row]); @@ -1983,17 +2031,21 @@ mod tests { assert_eq!(editor.diff().unwrap().focus, Focus::Before); editor.handle(Input::Down); editor.handle(Input::Right); - assert_eq!(editor.diff().unwrap().top, 1); - assert_eq!(editor.diff().unwrap().left_before, 1); + assert_eq!(editor.cursor().row, 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')); assert_eq!(editor.diff().unwrap().focus, Focus::After); - editor.handle(Input::Ctrl('j')); - editor.handle(Input::Ctrl('l')); - assert_eq!(editor.diff().unwrap().top, 2); + editor.handle(Input::Down); + editor.handle(Input::Right); + 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_before, 1); editor.handle(Input::End); - assert_eq!(editor.diff().unwrap().top, 5); + assert_eq!(editor.diff().unwrap().top, 2); editor.handle(Input::BackTab); assert_eq!(editor.diff().unwrap().focus, Focus::Before); editor.handle(Input::BackTab); @@ -2136,12 +2188,12 @@ mod tests { ] { editor.handle(input); } - assert_eq!(editor.context().text, original); - assert_eq!(editor.carets(), carets); editor.handle(Input::Escape); editor.handle(Input::Escape); assert!(editor.diff().is_some()); editor.execute("diff.close"); + assert_eq!(editor.context().text, original); + assert_eq!(editor.carets(), carets); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "original\\nsecond"); } diff --git a/src/editor.rs b/src/editor.rs index c37e635..0536d54 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -652,6 +652,38 @@ impl Serialize for CoreActionCatalog { } impl Action { + fn mutates(self, mode: Mode) -> bool { + matches!( + self, + Self::Insert + | Self::InsertFirstNonblank + | Self::Append + | Self::AppendLineEnd + | Self::OpenBelow + | Self::OpenAbove + | Self::JoinLines + | Self::Undo + | Self::Redo + | Self::RepeatLastEdit + | Self::Delete + | Self::DeleteLine + | Self::ChangeLine + | Self::Cut + | Self::Change + | Self::PasteBefore + | Self::PasteAfter + | Self::PasteSystemBefore + | Self::PasteSystemAfter + | Self::Newline + | Self::Save + | Self::SaveAndQuit + | Self::BufferDelete + | Self::BufferForceDelete + | Self::LspCompletion + ) || (self == Self::Backspace && mode == Mode::Insert) + || (self == Self::SnippetNext && mode == Mode::Insert) + } + fn named(mode: Mode, name: &str) -> Option { CORE_ACTIONS .iter() @@ -954,6 +986,8 @@ struct AnnotationSet { struct Buffer { id: usize, + diff_snapshot: Option<(u64, usize, Focus)>, + snapshot_document_path: Option, lines: Vec, saved_lines: Vec, text_revision: u64, @@ -997,6 +1031,8 @@ impl Buffer { let lines = split_lines(&text); Ok(Self { id, + diff_snapshot: None, + snapshot_document_path: None, saved_lines: lines.clone(), lines, text_revision: 0, @@ -1020,7 +1056,7 @@ impl Buffer { } fn dirty(&self) -> bool { - self.lines != self.saved_lines + self.diff_snapshot.is_none() && self.lines != self.saved_lines } fn info(&self, active: bool) -> BufferInfo<'_> { @@ -1036,7 +1072,10 @@ impl Buffer { DocumentInfo { id: self.id, revision: self.text_revision, - path: self.path.as_deref(), + path: self + .snapshot_document_path + .as_deref() + .or(self.path.as_deref()), lines: &self.lines, save_revision: self.save_revision, } @@ -1099,6 +1138,8 @@ pub struct Editor { count: Option, diff: Option, diff_prompt_mode: Option, + diff_bound: Option<(u64, usize, Focus, usize)>, + diff_return_buffer: Option, diff_context_options: (usize, usize), next_diff_request: u64, outstanding_diff_request: Option, @@ -1171,6 +1212,8 @@ impl Editor { count: None, diff: None, diff_prompt_mode: None, + diff_bound: None, + diff_return_buffer: None, diff_context_options: (10, 15), next_diff_request: 0, outstanding_diff_request: None, @@ -1239,7 +1282,7 @@ impl Editor { } pub fn buffer_count(&self) -> usize { - self.parked.len() + 1 + self.buffers().count() } pub fn has_buffer(&self, id: usize) -> bool { @@ -1250,11 +1293,13 @@ impl Editor { pub fn buffers(&self) -> impl Iterator> + '_ { self.parked .range(..self.active.id) + .filter(|(_, buffer)| buffer.diff_snapshot.is_none()) .map(|(_, buffer)| buffer.info(false)) - .chain(std::iter::once(self.active.info(true))) + .chain((self.active.diff_snapshot.is_none()).then(|| self.active.info(true))) .chain( self.parked .range(self.active.id..) + .filter(|(_, buffer)| buffer.diff_snapshot.is_none()) .map(|(_, buffer)| buffer.info(false)), ) } @@ -1262,15 +1307,26 @@ impl Editor { pub fn documents(&self) -> impl Iterator> + '_ { self.parked .range(..self.active.id) + .filter(|(_, buffer)| buffer.diff_snapshot.is_none()) .map(|(_, buffer)| buffer.document()) - .chain(std::iter::once(self.active.document())) + .chain((self.active.diff_snapshot.is_none()).then(|| self.active.document())) .chain( self.parked .range(self.active.id..) + .filter(|(_, buffer)| buffer.diff_snapshot.is_none()) .map(|(_, buffer)| buffer.document()), ) } + pub(crate) fn interaction_documents(&self) -> impl Iterator> + '_ { + self.documents().chain( + std::iter::once(&self.active) + .chain(self.parked.values()) + .filter(|buffer| buffer.diff_snapshot.is_some()) + .map(Buffer::document), + ) + } + pub fn project_root(&self) -> &Path { &self.project_root } @@ -1329,6 +1385,8 @@ impl Editor { } fn open_diff(&mut self) { + self.release_diff_buffers(); + self.diff_return_buffer = Some(self.active.id); self.count = None; self.clear_leader(); self.palette = None; @@ -1347,7 +1405,7 @@ impl Editor { fn binding_mode(&self) -> Mode { if self.diff.is_some() - && !self.diff_editing() + && !self.diff_interacting() && !matches!(self.active.mode, Mode::Command | Mode::Search) { Mode::Normal @@ -1364,48 +1422,162 @@ impl Editor { pub fn diff_editing(&self) -> bool { self.diff_editable() + && self.diff_interacting() && self .diff .as_ref() .is_some_and(|diff| diff.focus == Focus::After) } + pub fn diff_interacting(&self) -> bool { + self.diff_buffer_focus().is_some_and(|focus| { + self.diff + .as_ref() + .is_some_and(|diff| diff.focus == focus && diff.phase == Phase::View) + }) + } + + pub(crate) fn diff_buffer_focus(&self) -> Option { + let (comparison, file, focus, buffer) = self.diff_bound?; + self.diff + .as_ref() + .filter(|diff| { + diff.comparison_id == Some(comparison) + && diff.file == file + && self.active.id == buffer + }) + .map(|_| focus) + } + + pub(crate) fn mutation_blocked(&self) -> bool { + self.active.diff_snapshot.is_some() || (self.diff.is_some() && !self.diff_editing()) + } + + fn leave_diff_on_navigation(&mut self) { + if self + .diff_bound + .is_some_and(|bound| bound.3 != self.active.id) + { + if let Some(role) = self.active.diff_snapshot { + self.diff_bound = Some((role.0, role.1, role.2, self.active.id)); + if let Some(diff) = &mut self.diff { + if diff.file != role.1 + && let Some(path) = self.active.path.as_deref() + { + diff.select_file(path); + } + diff.focus = role.2; + } + } else { + self.release_diff_buffers(); + self.diff = None; + self.outstanding_diff_request = None; + } + } + } + + fn release_diff_buffers(&mut self) { + if self.active.diff_snapshot.is_some() + && let Some(id) = self + .diff + .as_ref() + .and_then(|diff| diff.live_buffer) + .or(self.diff_return_buffer) + && let Some(buffer) = self.parked.remove(&id) + { + self.activate(buffer); + } + self.parked + .retain(|_, buffer| buffer.diff_snapshot.is_none()); + self.jumps.retain(|jump| { + self.active.id == jump.buffer_id || self.parked.contains_key(&jump.buffer_id) + }); + self.jump_index = self.jump_index.min(self.jumps.len().saturating_sub(1)); + self.diff_bound = None; + self.diff_return_buffer = None; + } + fn bind_diff_buffer(&mut self) { if self.diff_prompt_mode.is_some() { return; } let Some(diff) = &self.diff else { return }; - if !diff.after_editable() { + if diff.phase != Phase::View { return; } - if self.diff_editable() { + let Some(comparison_id) = diff.comparison_id else { + return; + }; + if diff.pane_layout() == crate::diff::PaneLayout::Empty + || (diff.rows.is_empty() && !diff.after_editable()) + { + if self.active.diff_snapshot.is_some() + && let Some(buffer) = self + .diff_return_buffer + .and_then(|id| self.parked.remove(&id)) + { + self.activate(buffer); + } + self.diff_bound = None; return; } - let comparison = diff.comparison.as_ref().expect("editable comparison"); - let path = comparison.root.join(&comparison.files[diff.file].path); - let root = comparison.root.canonicalize(); - let opened = (|| -> io::Result<()> { - let metadata = fs::symlink_metadata(&path)?; - if !metadata.is_file() { - return Err(io::Error::other("Working file is not a regular file")); + let Some(comparison) = &diff.comparison else { + return; + }; + let Some(file) = comparison.files.get(diff.file) else { + return; + }; + let focus = if diff.focus == Focus::Files { + self.diff_buffer_focus().unwrap_or_else(|| { + if diff.after_editable() { + Focus::After + } else { + diff.first_text_focus() + } + }) + } else { + diff.focus + }; + let key = (comparison_id, diff.file, focus); + if self.diff_bound.is_some_and(|(id, file, side, buffer)| { + (id, file, side) == key && buffer == self.active.id + }) { + return; + } + let synchronized = self.diff_bound.and_then(|(id, file, from, buffer)| { + if id != comparison_id || file != diff.file || from == focus || buffer != self.active.id + { + return None; } - let identity = path.canonicalize()?; - if !identity.starts_with(root?) { - return Err(io::Error::other("Working file escapes repository root")); - } - if self.active.identity.as_ref() != Some(&identity) { - self.edit_buffer(path, false)?; - } - Ok(()) - })(); - match opened { - Ok(()) => { - let diff = self.diff.as_mut().expect("diff remains open"); - diff.live_buffer = Some(self.active.id); - diff.live_revision = None; - diff.follow_caret = true; - } - Err(error) => { + let target = self.active.targets[self.active.primary]; + let column = target + .preferred_columns + .map(|columns| columns.1) + .unwrap_or_else(|| { + display_column(&self.active.lines[target.active.row], target.active.column) + }); + diff.corresponding_row(from, focus, target.active.row) + .map(|row| (row, column)) + }); + let writable = focus == Focus::After && diff.after_editable(); + if writable { + let path = comparison.root.join(&file.path); + let root = comparison.root.canonicalize(); + let opened = (|| -> io::Result<()> { + let metadata = fs::symlink_metadata(&path)?; + if !metadata.is_file() { + return Err(io::Error::other("Working file is not a regular file")); + } + let identity = path.canonicalize()?; + if !identity.starts_with(root?) { + return Err(io::Error::other("Working file escapes repository root")); + } + if self.active.identity.as_ref() != Some(&identity) { + self.edit_buffer(path, false)?; + } + Ok(()) + })(); + if let Err(error) = opened { let diff = self.diff.as_mut().expect("diff remains open"); if let Some(comparison) = &mut diff.comparison { comparison @@ -1415,23 +1587,90 @@ impl Editor { diff.live_buffer = None; diff.live_revision = None; diff.error = Some(format!("Open failed: {error}")); + self.bind_diff_buffer(); + return; + } + let diff = self.diff.as_mut().expect("diff remains open"); + diff.live_buffer = Some(self.active.id); + diff.live_revision = None; + } else { + let existing = self + .parked + .values() + .find(|buffer| buffer.diff_snapshot == Some(key)) + .map(|buffer| buffer.id); + if let Some(id) = existing { + self.switch_buffer(id); + } else { + let mut buffer = + Buffer::open(self.next_buffer_id, None, None, false).expect("unnamed snapshot"); + buffer.diff_snapshot = Some(key); + buffer.path = Some(comparison.root.join(&file.path)); + buffer.snapshot_document_path = Some(loop { + let nonce = TEMP_ID.fetch_add(1, Ordering::Relaxed); + let directory = comparison + .root + .join(format!(".vamp-readonly-{}-{nonce}", std::process::id())); + let path = directory.join( + file.path + .file_name() + .unwrap_or_else(|| std::ffi::OsStr::new("snapshot")), + ); + if !directory.exists() + && !std::iter::once(&self.active) + .chain(self.parked.values()) + .any(|buffer| { + buffer.identity.as_ref() == Some(&path) + || buffer.document().path == Some(path.as_path()) + }) + { + break path; + } + }); + buffer.lines = diff.snapshot_lines(focus); + self.add_buffer(buffer); } } + self.diff_bound = Some((key.0, key.1, key.2, self.active.id)); + if let Some((row, wanted)) = synchronized { + let row = row.min(self.active.lines.len() - 1); + let cursor = Cursor { + row, + column: column_at_display(&self.active.lines[row], wanted, self.max_column(row)), + }; + let primary = self.active.primary; + self.active.targets[primary].active = cursor; + if self.active.mode != Mode::Visual { + self.active.targets[primary].anchor = cursor; + } + self.active.targets[primary].preferred_columns = Some((wanted, wanted)); + self.active.search_match = None; + self.normalize_targets(); + } + self.diff.as_mut().expect("diff remains open").follow_caret = true; } pub(crate) fn refresh_live_diff(&mut self) { - let Some(diff) = &mut self.diff else { return }; - if diff.live_buffer != Some(self.active.id) || !diff.after_editable() { - return; - } - if diff.live_revision != Some(self.active.text_revision) { + if let Some(diff) = &mut self.diff + && diff.live_buffer == Some(self.active.id) + && diff.after_editable() + && diff.live_revision != Some(self.active.text_revision) + { diff.refresh_after(&self.active.lines, self.active.text_revision); diff.follow_caret = true; } + let Some(focus) = self.diff_buffer_focus() else { + return; + }; let mut moved = false; for index in 0..self.active.targets.len() { let cursor = self.active.targets[index].active; - let Some(row) = self.diff.as_ref().unwrap().clamp_after_row(cursor.row) else { + let Some(row) = self + .diff + .as_ref() + .unwrap() + .clamp_source_row(focus, cursor.row) + else { continue; }; if row != cursor.row { @@ -1461,6 +1700,11 @@ impl Editor { } diff.navigate(down); } + self.bind_diff_buffer(); + self.refresh_live_diff(); + if let Some(diff) = &mut self.diff { + diff.follow_caret = false; + } } pub(crate) fn set_diff_viewport( @@ -1468,6 +1712,7 @@ impl Editor { comparison_id: u64, file: usize, top: usize, + left_before: usize, left_after: usize, ) { if let Some(diff) = &mut self.diff @@ -1475,36 +1720,47 @@ impl Editor { && diff.file == file { diff.top = top; + diff.left_before = left_before; diff.left_after = left_after; } } fn handle_diff(&mut self, input: Input) -> Option { - if input == Input::Escape - && (self.count.is_some() || self.g_pending || self.line_pending.is_some()) - { - return self.handle_input(input); - } if self.palette.is_some() - || (matches!(self.active.mode, Mode::Command | Mode::Search) - && (self.diff_prompt_mode.is_some() || self.diff_editing())) + || self.diff_prompt_mode.is_some() + || (self.diff_interacting() && self.active.mode != Mode::Normal) + || self.count.is_some() + || self.g_pending + || self.line_pending.is_some() { return self.handle_input(input); } - if input == Input::Char(':') && !self.diff_editing() { - return self.act(Action::Command); - } let phase = self.diff.as_ref().expect("active diff").phase; let files_focused = - phase == Phase::View && self.diff.as_ref().expect("active diff").focus == Focus::Files; - let live = self.diff_editing(); - if live && self.active.mode != Mode::Normal { - return self.handle_input(input); + phase == Phase::View && self.diff.as_ref().unwrap().focus == Focus::Files; + if self.diff_interacting() { + if matches!(input, Input::Char('[' | ']')) { + let focus = self.diff_buffer_focus().unwrap(); + let row = self.cursor().row; + self.diff.as_mut().unwrap().expand_source_excerpt( + focus, + input == Input::Char(']'), + row, + ); + return None; + } + if !matches!(input, Input::Char('\t') | Input::BackTab) { + return self.handle_input(input); + } } if files_focused && input == Input::Ctrl('w') { - let diff = self.diff.as_mut().expect("active diff"); + let diff = self.diff.as_mut().unwrap(); diff.files_visible = false; - diff.focus = diff.first_text_focus(); + diff.focus = self + .diff_bound + .map(|bound| bound.2) + .unwrap_or_else(|| diff.first_text_focus()); + diff.normalize_focus(); if let Some(files) = &mut diff.files { files.focused = false; } @@ -1513,14 +1769,14 @@ impl Editor { if files_focused && self.handle_tree_binding(&input) { return None; } - if !live { + if !self.diff_interacting() { match self.selection_bindings.get(&input) { Some(Binding::Action(Action::Down)) => { - self.diff.as_mut().expect("active diff").navigate(true); + self.diff.as_mut().unwrap().navigate(true); return None; } Some(Binding::Action(Action::Up)) => { - self.diff.as_mut().expect("active diff").navigate(false); + self.diff.as_mut().unwrap().navigate(false); return None; } Some(Binding::Disabled) => return None, @@ -1535,7 +1791,7 @@ impl Editor { .and_then(|diff| diff.files.as_ref()) .is_some_and(|files| files.searching || files.view == FileView::List) { - self.diff.as_mut().expect("active diff").handle(input); + self.diff.as_mut().unwrap().handle(input); return None; } if self.leader_pending().is_some() @@ -1545,74 +1801,40 @@ impl Editor { .get(&self.binding_mode()) .and_then(|keys| keys.get(&input)) .is_some_and(|binding| { - matches!(binding, Binding::Action(Action::DiffClose)) - || (phase == Phase::View || !matches!(input, Input::Char(_))) - && (matches!( - binding, - Binding::Command(_) - | Binding::Action( - Action::Command - | Action::Palette - | Action::FilePicker - | Action::FileExplorer - | Action::DiffClose - | Action::Quit - | Action::ForceQuit - | Action::Plugins - | Action::Buffers - ) - ) || (phase == Phase::View - && matches!( - binding, - Binding::Action( - Action::Left - | Action::Right - | Action::Up - | Action::Down - | Action::LineStart - | Action::LineEnd - | Action::DocumentStart - | Action::DocumentEnd - ) - ))) + matches!( + binding, + Binding::Command(_) + | Binding::Action( + Action::Command + | Action::Palette + | Action::FilePicker + | Action::FileExplorer + | Action::DiffClose + | Action::Quit + | Action::ForceQuit + | Action::Plugins + | Action::Buffers + ) + ) }) { return self.handle_input(input); } - if live && !matches!(input, Input::Char('\t' | '[' | ']') | Input::BackTab) { - return self.handle_input(input); - } - if live && matches!(input, Input::Char('[' | ']')) { - let row = self.cursor().row; - self.diff - .as_mut() - .expect("active diff") - .expand_after_excerpt(input == Input::Char(']'), row); - return None; - } - if !files_focused - && self - .diff - .as_ref() - .is_some_and(|diff| diff.phase == Phase::View) - && !matches!( - input, - Input::Char('\t' | '[' | ']') - | Input::BackTab - | Input::Escape - | Input::Ctrl('h' | 'l') - | Input::Home - | Input::End - ) + let escape = input == Input::Escape; + let back = input == Input::Escape && phase != Phase::View; + let return_focus = files_focused + .then(|| self.diff_bound.map(|bound| bound.2)) + .flatten(); + self.diff.as_mut().unwrap().handle(input); + if files_focused + && escape + && self.diff.as_ref().unwrap().focus != Focus::Files + && let Some(focus) = return_focus { - return self.handle_input(input); + let diff = self.diff.as_mut().unwrap(); + diff.focus = focus; + diff.normalize_focus(); } - let back = input == Input::Escape - && self - .diff - .as_ref() - .is_some_and(|diff| diff.phase != Phase::View); - self.diff.as_mut().expect("active diff").handle(input); if back { self.outstanding_diff_request = None; } @@ -1847,10 +2069,19 @@ impl Editor { if path.as_os_str().is_empty() || path.to_string_lossy().chars().any(char::is_control) { return Err("Navigation path must be nonempty and contain no controls".into()); } - self.edit_buffer(path, false) - .map_err(|error| error.to_string())?; + let snapshot = std::iter::once(&self.active) + .chain(self.parked.values()) + .find(|buffer| buffer.snapshot_document_path.as_ref() == Some(&path)) + .map(|buffer| buffer.id); + if let Some(id) = snapshot { + self.switch_buffer(id); + } else { + self.edit_buffer(path, false) + .map_err(|error| error.to_string())?; + } self.place_navigation(position); self.record_jump(source); + self.leave_diff_on_navigation(); Ok(()) } @@ -1904,6 +2135,7 @@ impl Editor { .active .identity .as_deref() + .or(self.active.path.as_deref()) .ok_or("Relative links require a named Markdown document")?; source_path .parent() @@ -2004,6 +2236,9 @@ impl Editor { } pub fn show_completion(&mut self, mut items: Vec) -> Result<(), String> { + if self.mutation_blocked() { + return Err("Diff pane is read-only".into()); + } if self.mode() != Mode::Insert || self.palette.is_some() || self.startup_diagnostic.is_some() @@ -3143,7 +3378,7 @@ impl Editor { } self.command_line = format!("{} ", entry.name); self.palette = None; - if self.diff.is_some() && !self.diff_editing() { + if self.diff.is_some() && !self.diff_interacting() { self.diff_prompt_mode.get_or_insert(self.active.mode); } else if self.active.mode == Mode::Insert { self.finish_insert(); @@ -3293,9 +3528,10 @@ impl Editor { } else { self.handle_input(input) }; + self.leave_diff_on_navigation(); self.bind_diff_buffer(); self.refresh_live_diff(); - if self.diff_editing() + if self.diff_interacting() && let Some(diff) = &mut self.diff { diff.follow_caret = true; @@ -3531,7 +3767,7 @@ impl Editor { } fn open_action_prompt(&mut self, entry: &CoreAction) { - if self.diff.is_some() && !self.diff_editing() { + if self.diff.is_some() && !self.diff_interacting() { self.diff_prompt_mode.get_or_insert(self.active.mode); self.palette = None; self.count = None; @@ -3550,48 +3786,12 @@ impl Editor { self.clear_leader(); return None; } - let read_only = self.diff.is_some() && !self.diff_editing(); - if self.diff.is_some() && !self.diff_editing() { - match action { - Action::Command - | Action::Palette - | Action::FilePicker - | Action::FileExplorer - | Action::Diff - | Action::DiffClose - | Action::Quit - | Action::ForceQuit - | Action::Plugins - | Action::Buffers => {} - Action::Normal if self.active.mode != Mode::Normal => {} - Action::Submit | Action::Backspace - if matches!(self.active.mode, Mode::Command | Mode::Search) => {} - Action::Up - | Action::Down - | Action::Left - | Action::Right - | Action::LineStart - | Action::LineEnd - | Action::DocumentStart - | Action::DocumentEnd => { - let input = match action { - Action::Up => Input::Up, - Action::Down => Input::Down, - Action::Left => Input::Left, - Action::Right => Input::Right, - Action::LineStart | Action::DocumentStart => Input::Home, - _ => Input::End, - }; - self.diff.as_mut().expect("active diff").handle(input); - return None; - } - _ => { - self.set_message("Diff pane is read-only"); - self.count = None; - self.clear_leader(); - return None; - } - } + let read_only = self.mutation_blocked(); + if read_only && action.mutates(self.active.mode) { + self.set_message("Diff pane is read-only"); + self.count = None; + self.clear_leader(); + return None; } let entry = action.entry(); if entry.ex.is_some() { @@ -3743,7 +3943,7 @@ impl Editor { Action::Redo => self.redo(), Action::RepeatLastEdit => self.repeat_last_edit(), Action::Command | Action::Search => { - if read_only { + if self.diff.is_some() && !self.diff_interacting() { self.diff_prompt_mode.get_or_insert(self.active.mode); } self.active.mode = if matches!(action, Action::Search) { @@ -4022,20 +4222,18 @@ impl Editor { } fn adjacent_buffer(&self, forward: bool) -> usize { - use std::ops::Bound::{Excluded, Unbounded}; - let id = self.active.id; + let mut buffers = self.buffers().filter(|buffer| !buffer.active); if forward { - self.parked - .range((Excluded(id), Unbounded)) - .next() - .or_else(|| self.parked.first_key_value()) + buffers + .find(|buffer| buffer.id > self.active.id) + .or_else(|| self.buffers().find(|buffer| !buffer.active)) } else { - self.parked - .range(..id) - .next_back() - .or_else(|| self.parked.last_key_value()) + buffers + .filter(|buffer| buffer.id < self.active.id) + .last() + .or_else(|| self.buffers().filter(|buffer| !buffer.active).last()) } - .map_or(id, |(id, _)| *id) + .map_or(self.active.id, |buffer| buffer.id) } fn edit_buffer(&mut self, path: PathBuf, allow_missing: bool) -> io::Result<()> { @@ -4146,6 +4344,13 @@ impl Editor { None }; match id { + Some(id) + if self + .buffer(id) + .is_some_and(|buffer| buffer.diff_snapshot.is_some()) => + { + self.set_message(format!("No buffer {id}")) + } Some(id) if action == Action::Buffer => self.switch_buffer(id), Some(id) => self.delete_buffer(id, action == Action::BufferForceDelete), None => self.set_message(format!( @@ -4172,6 +4377,7 @@ impl Editor { pub fn execute(&mut self, command: &str) -> Option { self.finish_diff_prompt(); let invocation = self.execute_command(command); + self.leave_diff_on_navigation(); self.refresh_live_diff(); invocation } @@ -4228,6 +4434,7 @@ impl Editor { self.finish_diff_prompt(); if entry.action == Action::DiffClose { if args.is_empty() { + self.release_diff_buffers(); self.diff = None; self.outstanding_diff_request = None; self.palette = None; @@ -4242,13 +4449,7 @@ impl Editor { } return None; } - if self.diff.is_some() - && !self.diff_editing() - && !matches!( - entry.action, - Action::Diff | Action::Quit | Action::ForceQuit | Action::Plugins | Action::Buffers - ) - { + if self.mutation_blocked() && entry.action.mutates(self.active.mode) { self.set_message("Diff pane is read-only"); return None; } @@ -4348,8 +4549,7 @@ impl Editor { /// to the primary caret: Insert runs once there, Replace replaces the buffer. /// Rejected responses and message-only effects retain the complete set. pub fn apply(&mut self, effects: Vec) -> Result<(), String> { - if self.diff.is_some() - && !self.diff_editing() + if self.mutation_blocked() && effects .iter() .any(|effect| matches!(effect, Effect::Insert(_) | Effect::Replace(_))) @@ -4369,7 +4569,7 @@ impl Editor { } validate_text(text)?; } - if self.diff.is_some() && !self.diff_editing() { + if self.mutation_blocked() { for effect in effects { if let Effect::Message(text) = effect { self.set_message(text); @@ -4437,6 +4637,10 @@ impl Editor { } fn save(&mut self, path: Option) -> bool { + if self.mutation_blocked() { + self.set_message("Diff pane is read-only"); + return false; + } let Some(path) = path.or_else(|| self.active.path.clone()) else { self.set_message("No file name; use :w path"); return false; @@ -4763,7 +4967,7 @@ impl Editor { let mut carets = Vec::new(); let mut primary = 0; for (index, rows) in rows.into_iter().enumerate() { - for row in rows { + for row in self.navigable_rows(*rows.start(), rows.end().saturating_add(1)) { if index == self.active.primary && row == primary_row { primary = carets.len(); } @@ -4871,7 +5075,9 @@ impl Editor { }) .collect(); // ponytail: literal scan is O(buffer graphemes × phrase bytes); index only for large buffers. - let diff = self.diff.as_ref().filter(|_| self.diff_editable()); + let diff = self + .diff_buffer_focus() + .and_then(|focus| self.diff.as_ref().map(|diff| (diff, focus))); let candidates: Vec<_> = self .navigable_rows(0, self.active.lines.len()) .flat_map(|row| { @@ -4887,11 +5093,11 @@ impl Editor { && GraphemeCursor::new(end, text.len(), true) .is_boundary(&text, 0) .expect("complete buffer supplies grapheme context") - && diff.is_none_or(|diff| { + && diff.is_none_or(|(diff, focus)| { let last = offsets .partition_point(|offset| *offset < end) .saturating_sub(1); - diff.after_display_row(last).is_some() + diff.source_display_row(focus, last).is_some() })) .then_some((byte, end)) }) @@ -5808,6 +6014,9 @@ impl Editor { } fn restore(&mut self, snapshot: Snapshot) { + if self.mutation_blocked() { + return; + } if self.active.lines != snapshot.lines { self.active.text_changed(); } @@ -5821,6 +6030,9 @@ impl Editor { } fn undo(&mut self) { + if self.mutation_blocked() { + return; + } if let Some(before) = self.active.undo.pop() { self.active.redo.push(self.snapshot()); self.restore(before); @@ -5830,6 +6042,9 @@ impl Editor { } fn redo(&mut self) { + if self.mutation_blocked() { + return; + } if let Some(after) = self.active.redo.pop() { self.active.undo.push(self.snapshot()); self.restore(after); @@ -5843,10 +6058,12 @@ impl Editor { start: usize, end: usize, ) -> impl DoubleEndedIterator + '_ { - let diff = self.diff.as_ref().filter(|_| self.diff_editable()); + let diff = self + .diff_buffer_focus() + .and_then(|focus| self.diff.as_ref().map(|diff| (diff, focus))); (start..if diff.is_none() { end } else { start }).chain( diff.into_iter() - .flat_map(DiffState::after_rows) + .flat_map(|(diff, focus)| diff.source_rows(focus)) .filter(move |row| start <= *row && *row < end), ) } @@ -5864,7 +6081,7 @@ impl Editor { .min(self.max_column(cursor.row)) } Action::Down | Action::Up => { - if self.diff_editable() { + if self.diff_buffer_focus().is_some() { let step = |cursor: Cursor, row| Cursor { row, column: cursor.column.min(self.max_column(row)), @@ -6031,6 +6248,9 @@ impl Editor { original: Option<&Snapshot>, preserve_targets: bool, ) { + if self.mutation_blocked() { + return; + } if edits.is_empty() { return; } @@ -7075,6 +7295,418 @@ mod tests { buffers } + fn historical_diff(editor: &mut Editor, root: &Path, before: Vec, after: Vec) { + use crate::diff::{ + Catalog, Category, ChangedFile, Comparison, FileStatus, RepoKind, Revision, + }; + editor.execute("diff"); + let (id, _) = editor.take_diff_request().unwrap(); + let revision = |id: &str| Revision { + target: RevisionTarget::Commit(id.into()), + label: id.into(), + description: String::new(), + category: Category::Commits, + }; + editor.apply_diff_result( + id, + Ok(Outcome::Catalog(Catalog { + kind: RepoKind::Git, + entries: vec![revision("base"), revision("head")], + notice: None, + })), + ); + editor.handle(Input::Enter); + editor.handle(Input::Down); + editor.handle(Input::Enter); + let (id, _) = editor.take_diff_request().unwrap(); + let hunks = crate::diff::line_hunks(&before, false, &after, false); + editor.apply_diff_result( + id, + Ok(Outcome::Comparison(Comparison { + root: root.to_owned(), + before: "base".into(), + after: "head".into(), + editable_paths: Default::default(), + files: vec![ChangedFile { + path: "file.txt".into(), + old_path: None, + status: FileStatus::Modified, + before, + after, + before_eof: false, + after_eof: false, + hunks, + notice: None, + }], + })), + ); + editor.handle(Input::Char('\t')); + assert_eq!(editor.diff().unwrap().focus, Focus::Before); + assert!(editor.diff_interacting()); + } + + #[test] + fn readonly_native_panes_allow_selection_yank_search_and_multicarets() { + for focus in [Focus::Before, Focus::After] { + let directory = Directory::new(); + let mut editor = editor(); + let lines = ["alpha beta alpha", "second line", "third line"] + .map(str::to_owned) + .to_vec(); + let mut after = lines.clone(); + after[2] = "third changed".into(); + historical_diff(&mut editor, &directory.0, lines.clone(), after.clone()); + if focus == Focus::After { + editor.handle(Input::Char('\t')); + } + let id = editor.buffer_id(); + type_text(&mut editor, "2w"); + assert_eq!(editor.cursor().column, 11); + type_text(&mut editor, "0v4ly"); + assert_eq!(editor.clipboard, ["alpha"]); + assert_eq!(editor.mode(), Mode::Normal); + type_text(&mut editor, "Vjy"); + assert!(editor.clipboard_linewise); + assert_eq!(editor.clipboard, ["alpha beta alpha\nsecond line\n"]); + type_text(&mut editor, "gt/alpha\n"); + assert_eq!(editor.cursor(), Cursor { row: 0, column: 11 }); + editor.handle(Input::Char('n')); + assert_eq!(editor.cursor(), Cursor::default()); + editor.handle(Input::Ctrl('e')); + assert_eq!(editor.selection_count(), 2); + editor.handle(Input::Escape); + type_text(&mut editor, "gtv4l"); + editor.handle(Input::Ctrl('a')); + assert_eq!(editor.selection_count(), 2); + assert_eq!( + editor.lines(), + if focus == Focus::Before { + &lines + } else { + &after + } + ); + assert_eq!(editor.buffer_id(), id); + assert_eq!(editor.buffer_count(), 1); + assert_eq!(editor.documents().count(), 1); + assert!(!editor.dirty()); + } + } + + #[test] + fn readonly_native_mutation_rejection_is_atomic_and_cannot_write_disk() { + for focus in [Focus::Before, Focus::After] { + let directory = Directory::new(); + let path = directory.path("file.txt"); + fs::write(&path, "disk").unwrap(); + let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); + editor.set_clipboard_mode("separate").unwrap(); + type_text(&mut editor, "iunsaved"); + editor.handle(Input::Escape); + let original_id = editor.buffer_id(); + historical_diff( + &mut editor, + &directory.0, + vec!["snapshot text".into()], + vec!["historic text".into()], + ); + if focus == Focus::After { + editor.handle(Input::Char('\t')); + } + editor + .bind("normal", "z", Some("core.insert".into())) + .unwrap(); + editor + .bind("normal", "leader d", Some("core.delete_line".into())) + .unwrap(); + editor + .bind("normal", "Q", Some("core.save".into())) + .unwrap(); + let text = editor.lines().to_vec(); + let revision = editor.text_revision(); + for keys in [ + "i", "a", "o", "O", "dd", "cc", "x", "J", "u", ".", "p", "z", " d", "Q", + ] { + type_text(&mut editor, keys); + assert_eq!(editor.mode(), Mode::Normal, "{keys}"); + assert_eq!(editor.lines(), text, "{keys}"); + } + editor.handle(Input::Ctrl('r')); + editor.handle(Input::Paste("literal paste".into())); + type_text(&mut editor, "vl"); + let selections = editor.selections(); + for keys in ["d", "c", "p", "."] { + type_text(&mut editor, keys); + assert_eq!(editor.selections(), selections, "{keys}"); + assert_eq!(editor.lines(), text, "{keys}"); + } + for effect in [ + Effect::Insert("plugin".into()), + Effect::Replace("plugin".into()), + ] { + assert!( + editor + .apply(vec![Effect::Message("must not leak".into()), effect]) + .is_err() + ); + assert_eq!(editor.selections(), selections); + assert_eq!(editor.lines(), text); + } + for command in [ + "w".to_owned(), + format!("w {}", path.display()), + format!("wq {}", path.display()), + "save_and_quit".into(), + ] { + editor.execute(&command); + assert_eq!(fs::read_to_string(&path).unwrap(), "disk"); + assert!(!editor.should_quit()); + } + assert_eq!(editor.text_revision(), revision); + let snapshot_id = editor.buffer_id(); + editor.execute("diff.close"); + assert_eq!(editor.buffer_id(), original_id); + assert_eq!(editor.lines(), ["unsaveddisk"]); + assert!(!editor.has_buffer(snapshot_id)); + assert_eq!(editor.parked.len(), 0); + editor.handle(Input::Char('u')); + assert_eq!(editor.lines(), ["disk"]); + } + } + + #[test] + fn diff_focus_maps_aligned_rows_and_retains_display_column_through_short_lines() { + let directory = Directory::new(); + let mut editor = editor(); + let before = ["start", "removed", "anchor", "\t界abcdef", "tail"] + .map(str::to_owned) + .to_vec(); + let after = ["start", "added one", "added two", "anchor", "\t界a", "tail"] + .map(str::to_owned) + .to_vec(); + historical_diff(&mut editor, &directory.0, before, after); + type_text(&mut editor, "3j6l"); + let original = editor.cursor(); + assert_eq!(original, Cursor { row: 3, column: 6 }); + editor.handle(Input::Char('\t')); + assert_eq!(editor.cursor(), Cursor { row: 4, column: 2 }); + editor.handle(Input::BackTab); + assert_eq!(editor.cursor(), original); + editor.scroll_diff(Focus::After, true); + assert_eq!(editor.cursor(), Cursor { row: 4, column: 2 }); + editor.scroll_diff(Focus::Before, false); + assert_eq!(editor.cursor(), original); + editor.handle(Input::BackTab); + assert_eq!(editor.diff().unwrap().focus, Focus::Files); + editor.handle(Input::Escape); + assert_eq!(editor.diff().unwrap().focus, Focus::Before); + assert_eq!(editor.cursor(), original); + type_text(&mut editor, "gtj"); + editor.handle(Input::Char('\t')); + assert_eq!(editor.cursor().row, 1); + type_text(&mut editor, "gt2j"); + assert_eq!(editor.cursor().row, 2); + assert!( + editor + .diff() + .unwrap() + .rows + .iter() + .any(|row| row.after == Some(2) && row.before.is_none()) + ); + editor.handle(Input::BackTab); + assert_eq!(editor.cursor().row, 1); + } + + #[test] + fn readonly_excerpts_skip_hidden_rows_for_motions_search_clones_and_expand_only_normal() { + let directory = Directory::new(); + let mut editor = editor(); + let before: Vec<_> = (0..60).map(|row| format!("line {row}")).collect(); + let mut after = before.clone(); + after[10] = "changed 10".into(); + after[40] = "changed 40".into(); + editor.set_diff_context_options(1, 3); + historical_diff(&mut editor, &directory.0, before, after); + for focus in [Focus::Before, Focus::After] { + if editor.diff().unwrap().focus != focus { + editor.handle(Input::Char('\t')); + } + type_text(&mut editor, "gt3j"); + assert_eq!(editor.cursor().row, 39); + type_text(&mut editor, "gt2j"); + assert_eq!(editor.cursor().row, 11); + editor.handle(Input::Ctrl('e')); + assert_eq!( + editor + .carets() + .iter() + .map(|cursor| cursor.row) + .collect::>(), + [11, 39] + ); + editor.handle(Input::Escape); + type_text(&mut editor, "/line 20\n"); + assert!(editor.message().starts_with("Not found:")); + type_text(&mut editor, "gtv]"); + assert_eq!(editor.mode(), Mode::Visual); + assert!( + editor + .diff() + .unwrap() + .source_display_row(focus, 12) + .is_none() + ); + editor.handle(Input::Escape); + type_text(&mut editor, "gt]"); + assert!( + editor + .diff() + .unwrap() + .source_display_row(focus, 12) + .is_some() + ); + // Rebuild the excerpt configuration before checking the other pane. + editor.set_diff_context_options(2, 3); + editor.set_diff_context_options(1, 3); + } + } + + #[test] + fn readonly_snapshots_keep_pane_selection_and_safe_document_identity_for_navigation() { + let directory = Directory::new(); + let mut editor = editor(); + let before = ["alpha beta", "second line", "before tail"] + .map(str::to_owned) + .to_vec(); + let after = ["alpha beta", "second line", "after tail"] + .map(str::to_owned) + .to_vec(); + historical_diff(&mut editor, &directory.0, before, after); + let before_id = editor.buffer_id(); + let source_path = editor.path().unwrap().to_owned(); + let snapshot_path = editor + .interaction_documents() + .find(|document| document.id == before_id) + .unwrap() + .path + .unwrap() + .to_owned(); + assert_ne!(snapshot_path, source_path); + assert!(!snapshot_path.exists()); + assert!(editor.active.identity.is_none()); + type_text(&mut editor, "v3l"); + editor.handle(Input::Ctrl('e')); + let anchor = editor.active.targets[editor.active.primary].anchor; + let carets = editor.selection_count(); + editor.scroll_diff(Focus::After, true); + assert_eq!(editor.mode(), Mode::Normal); + editor.scroll_diff(Focus::Before, false); + assert_eq!(editor.buffer_id(), before_id); + assert_eq!(editor.mode(), Mode::Visual); + assert_eq!(editor.selection_count(), carets); + assert_eq!(editor.active.targets[editor.active.primary].anchor, anchor); + editor + .navigate_to(snapshot_path, TextPosition { row: 1, column: 2 }) + .unwrap(); + assert!(editor.diff_interacting()); + assert_eq!(editor.cursor(), Cursor { row: 1, column: 2 }); + editor + .register(Feature { + name: "readonly_fixture".into(), + required_capabilities: vec![], + core: false, + commands: vec!["readonly_fixture.command".into()], + }) + .unwrap(); + editor + .bind("normal", "R", Some("readonly_fixture.command".into())) + .unwrap(); + let invocation = editor.handle(Input::Char('R')).unwrap(); + assert_eq!(invocation.plugin, "readonly_fixture"); + assert!( + editor + .apply(vec![Effect::Insert("blocked".into())]) + .is_err() + ); + editor.handle(Input::Ctrl('p')); + type_text(&mut editor, "core.word_end"); + editor.handle(Input::Enter); + assert_eq!(editor.cursor().column, 5); + editor.execute("diff.close"); + assert!( + editor + .interaction_documents() + .all(|document| document.id != before_id) + ); + } + + #[test] + fn no_native_diff_pane_cannot_mutate_hidden_live_buffer() { + let directory = Directory::new(); + let path = directory.path("file.txt"); + fs::write(&path, "original").unwrap(); + let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); + working_diff(&mut editor, &directory.0, &["file.txt"]); + let file = &mut editor + .diff + .as_mut() + .unwrap() + .comparison + .as_mut() + .unwrap() + .files[0]; + file.status = crate::diff::FileStatus::Renamed; + file.old_path = Some("old.txt".into()); + file.before = file.after.clone(); + file.before_eof = file.after_eof; + file.hunks.clear(); + editor.handle(Input::Char('\t')); + assert!(!editor.diff_interacting()); + type_text(&mut editor, "iddccx"); + editor + .apply(vec![Effect::Insert("blocked".into())]) + .unwrap_err(); + editor.execute(&format!("w {}", path.display())); + assert_eq!(fs::read_to_string(path).unwrap(), "original"); + editor.execute("diff.close"); + assert_eq!(editor.lines(), ["original"]); + } + + #[test] + fn readonly_follow_link_and_jump_use_native_navigation_without_snapshot_identity() { + let directory = Directory::new(); + let mut editor = editor(); + let before = ["[go](#section)", "# Section", "before"] + .map(str::to_owned) + .to_vec(); + let after = ["[go](#section)", "# Section", "after"] + .map(str::to_owned) + .to_vec(); + historical_diff(&mut editor, &directory.0, before, after); + editor.active.path = Some(directory.path("file.md")); + type_text(&mut editor, "gf"); + assert_eq!(editor.cursor().row, 1); + assert!(editor.diff_interacting()); + editor.handle(Input::Ctrl('o')); + assert_eq!(editor.cursor().row, 0); + assert!(editor.diff_interacting()); + let target = directory.path("target.md"); + fs::write(&target, "# Target\n").unwrap(); + editor.execute("diff.close"); + historical_diff( + &mut editor, + &directory.0, + vec!["[go](target.md)".into()], + vec!["[after](target.md)".into()], + ); + editor.active.path = Some(directory.path("file.md")); + type_text(&mut editor, "gf"); + assert!(editor.diff().is_none()); + assert_eq!(editor.path(), Some(target.as_path())); + assert_eq!(editor.lines(), ["# Target", ""]); + } + #[test] fn editable_diff_brackets_expand_caret_excerpt_without_moving_source_anchor() { for selected in [30, 50] { @@ -7101,7 +7733,13 @@ mod tests { let top = editor.diff().unwrap().after_display_row(9).unwrap(); editor.diff.as_mut().unwrap().top = top; let cursor = editor.cursor(); - let visible = |editor: &Editor| editor.diff().unwrap().after_rows().collect::>(); + let visible = |editor: &Editor| { + editor + .diff() + .unwrap() + .source_rows(Focus::After) + .collect::>() + }; assert_eq!( visible(&editor), (9..12).chain(29..32).chain(49..52).collect::>() @@ -7278,9 +7916,9 @@ mod tests { editor.handle(Input::BackTab); type_text(&mut editor, "iX"); editor.execute("w"); - assert_eq!(editor.lines(), ["main"]); assert_eq!(fs::read_to_string(&target).unwrap(), "outside"); editor.execute("diff.close"); + assert_eq!(editor.lines(), ["main"]); } } @@ -7304,7 +7942,6 @@ mod tests { assert_eq!(editor.buffer_count(), 1); type_text(&mut editor, "iX"); editor.execute("w"); - assert_eq!(editor.lines(), ["main"]); assert_eq!(fs::read_to_string(&other).unwrap(), "other"); let diff = editor.diff.as_mut().unwrap(); diff.after.as_mut().unwrap().target = RevisionTarget::Commit("old".into()); @@ -7313,6 +7950,7 @@ mod tests { assert!(!editor.diff_editable()); assert_eq!(editor.buffer_count(), 1); editor.execute("diff.close"); + assert_eq!(editor.lines(), ["main"]); } #[test] diff --git a/src/main.rs b/src/main.rs index ea79d73..3f5f2c8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -44,6 +44,8 @@ Buffers: :edit PATH, :buffers, :buffer N, :buffer_next, :buffer_previous, :buffe Markdown links: :follow_link (primary caret; local files only) 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 + 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 Normal [/] explicitly expands context; Insert brackets remain literal text :diff.close exits explicitly; Esc cancels modes/prompts/filtering without closing the diff diff --git a/src/session.rs b/src/session.rs index 6296c8e..7edb2cb 100644 --- a/src/session.rs +++ b/src/session.rs @@ -263,10 +263,11 @@ impl EditorSession { comparison_id: u64, file: usize, top: usize, + left_before: usize, left_after: usize, ) { 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) { @@ -405,7 +406,7 @@ impl EditorSession { return; } 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) { Some(plugin) if plugin.is_async() => { let requests = self.requests.entry(invocation.plugin.clone()).or_default(); @@ -483,7 +484,7 @@ impl EditorSession { } let mut events = Vec::new(); 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 previous = self.documents.get(&document.id); let renamed = previous @@ -576,7 +577,7 @@ impl EditorSession { fn source_is_current(&self, source: InvocationSource) -> bool { self.editor - .documents() + .interaction_documents() .any(|document| document.id == source.buffer_id && document.revision == source.revision) } @@ -586,7 +587,7 @@ impl EditorSession { request_id: Option, source: InvocationSource, ) -> bool { - if self.editor.diff().is_some() && !self.editor.diff_editing() { + if self.editor.diff().is_some() && !self.editor.diff_interacting() { return false; } self.editor.buffer_id() == source.buffer_id @@ -622,6 +623,7 @@ impl EditorSession { fn completion_is_current(&self, request_id: Option, source: InvocationSource) -> bool { self.pending_completion + && !self.editor.mutation_blocked() && self.request_is_current("core", request_id, source) && self.editor.mode() == Mode::Insert && self.editor.palette().is_none() @@ -855,7 +857,7 @@ impl EditorSession { } return Ok(changed); } - if self.editor.diff().is_some() && !self.editor.diff_editing() { + if self.editor.mutation_blocked() { return Ok(false); } let changed = !effects.is_empty(); @@ -3425,29 +3427,35 @@ end .apply_response("fixture", 2, vec![Effect::Insert("stale".into())]) .unwrap() ); - let current = InvocationSource { + session.editor.scroll_diff(Focus::Before, false); + let readonly = InvocationSource { buffer_id: session.editor.buffer_id(), revision: session.editor.text_revision(), }; - session.editor.scroll_diff(Focus::Before, false); session .requests .get_mut("fixture") .unwrap() .pending - .insert(3, current); + .insert(3, readonly); assert!( !session .apply_response("fixture", 3, vec![Effect::Insert("readonly".into())]) .unwrap() ); - assert_eq!(session.editor.lines(), ["acceptedbase"]); + assert_eq!(session.editor.lines(), ["base"]); + assert!( + session + .editor + .documents() + .any(|document| document.lines == ["acceptedbase"]) + ); session .requests .get_mut("fixture") .unwrap() .pending - .insert(4, current); + .insert(4, readonly); assert!( session .apply_response("fixture", 4, vec![Effect::Message("allowed".into())]) diff --git a/src/tui.rs b/src/tui.rs index 7e0db8e..8b86b7d 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -174,13 +174,13 @@ impl BufferViewports { fn draw_session(&mut self, frame: &mut Frame, session: &mut EditorSession) { 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 .diff .as_ref() .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 { 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 { @@ -915,6 +915,7 @@ fn paint_diff_editor( editor: &Editor, diff: &DiffState, area: Rect, + focus: Focus, viewport: (usize, usize), ) -> Option { let (top, left) = viewport; @@ -931,12 +932,16 @@ fn paint_diff_editor( let DisplayRow::Line(aligned) = *row else { 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; }; 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); - 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); if column >= left && column < left.saturating_add(usize::from(area.width)) { 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(after), diff.focus, - editor.filter(|editor| editor.diff_editable()).map_or_else( - || "read-only".into(), - |editor| format!( - "After editable · {}{} · {}:{}", - editor.mode(), - if editor.dirty() { " [+]" } else { "" }, - editor.cursor().row + 1, - editor.cursor().column + 1, - ), - ) + editor + .filter(|editor| editor.diff_interacting()) + .map_or_else( + || "read-only".into(), + |editor| format!( + "{} · {}{} · {}:{}", + if editor.diff_editing() { + "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)), header, @@ -1289,25 +1305,42 @@ fn draw_diff( }; let mut document_caret = None; let mut top = diff.top; + let mut before_left = diff.left_before; let mut after_left = diff.left_after; - if let Some(editor) = editor.filter(|editor| { - editor.diff_editing() && diff.follow_caret && after_area != Rect::default() - }) { - let height = usize::from(after_area.height.saturating_sub(2)); - if let Some(row) = diff.after_display_row(editor.cursor().row) { + if let Some(editor) = editor.filter(|editor| editor.diff_interacting() && diff.follow_caret) + && let Some(focus) = editor.diff_buffer_focus() + { + let (pane, left) = match focus { + 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 { top = row; } else if height > 0 && row >= top.saturating_add(height) { top = row + 1 - height; } } - let digits = editor.lines().len().max(1).ilog10() as usize + 1; - let width = usize::from(after_area.width.saturating_sub(2)).saturating_sub(digits + 2); + let source_len = selected.map_or(0, |file| match focus { + 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); - if column < after_left { - after_left = column; - } else if width > 0 && column >= after_left.saturating_add(width) { - after_left = column + 1 - width; + if column < *left { + *left = column; + } else if width > 0 && column >= left.saturating_add(width) { + *left = column + 1 - width; } } if let Some(picker) = &diff.files @@ -1329,15 +1362,28 @@ fn draw_diff( diff_panel(frame, before_area, "", diff.focus == Focus::Before); } else { if before_area != Rect::default() { - draw_diff_pane( + let text_area = draw_diff_pane( frame, diff, file, before_area, true, &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() { let text_area = draw_diff_pane( @@ -1349,10 +1395,18 @@ fn draw_diff( &syntax.after, (top, after_left), ); - if let Some(editor) = editor.filter(|editor| editor.diff_editable()) { - document_caret = - paint_diff_editor(frame, editor, diff, text_area, (top, after_left)) - .map(|position| (text_area, position)); + if let Some(editor) = + editor.filter(|editor| editor.diff_buffer_focus() == Some(Focus::After)) + { + 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( 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)), help, @@ -1381,7 +1435,7 @@ fn draw_diff( ], viewport: diff .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 && diff.files_visible @@ -2732,7 +2786,8 @@ mod tests { ); assert_eq!(present.x, 0); 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!( terminal.backend().buffer()[(present.x, present.y)].fg, @@ -3514,7 +3569,6 @@ mod tests { .unwrap(); assert!(screen_text(&terminal).contains("deleted-lines.txt")); assert!(viewports.document.geometry.is_none()); - assert_eq!(editor.lines(), original); editor.handle(Input::Escape); assert!(editor.diff().is_some()); editor.execute("diff.close"); @@ -3523,9 +3577,241 @@ mod tests { .unwrap(); assert!(screen_text(&terminal).contains("original")); assert!(viewports.document.geometry.is_some()); + assert_eq!(editor.lines(), original); 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, + 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::>() + .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] fn editable_after_renders_live_syntax_caret_visual_selection_and_command_prompt() { let directory = Directory::new(); @@ -3807,9 +4093,10 @@ mod tests { assert_eq!(diff.files.as_ref().unwrap().selected, selected); assert!(!diff.files.as_ref().unwrap().focused); 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().cursor(), cursor); - assert!(viewports.document.geometry.is_none()); } #[test] diff --git a/tests/diff.rs b/tests/diff.rs index 5ba5034..96a6a4a 100644 --- a/tests/diff.rs +++ b/tests/diff.rs @@ -143,8 +143,7 @@ fn typed_diff_edits_unsaved_working_after_and_preserves_undo_on_explicit_close() } session.handle(Input::Escape); assert!(session.editor().diff().is_some()); - assert_eq!(session.editor().context().text, "uafter\n"); - assert_eq!(session.editor().cursor(), cursor); + assert_eq!(session.editor().context().text, "before\n"); assert_eq!(fs::read_to_string(&source).unwrap(), "after\n"); 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, 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] { session.handle(input); } @@ -320,3 +321,63 @@ fn editable_diff_navigation_skips_omitted_lines_without_expanding_excerpts() { type_query(&mut session, "]"); 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::>(); + 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() + ); +}