From 9a34669c0828bf849dfce710a0c872e700ddd2cc Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Sun, 11 Oct 2026 02:13:16 +0200 Subject: [PATCH] feat: edit working-checkout comparisons with normal editor actions --- Cargo.lock | 7 + Cargo.toml | 1 + README.md | 2 +- docs/configuration.md | 9 +- docs/editing.md | 14 +- src/diff.rs | 679 ++++++++++++++++++++++++++++-------- src/editor.rs | 774 +++++++++++++++++++++++++++++++++++++++--- src/files.rs | 125 +++---- src/main.rs | 8 +- src/repository.rs | 155 +++++---- src/ruby.rs | 14 + src/ruby_values.rb | 23 +- src/session.rs | 161 ++++++++- src/tui.rs | 763 +++++++++++++++++++++++++++-------------- tests/diff.rs | 51 ++- tests/repository.rs | 138 +++++++- 16 files changed, 2277 insertions(+), 647 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 00f5977..1cb6349 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1342,6 +1342,12 @@ dependencies = [ "libc", ] +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + [[package]] name = "siphasher" version = "1.0.4" @@ -1839,6 +1845,7 @@ dependencies = [ "ratatui", "serde", "serde_json", + "similar", "tree-sitter", "tree-sitter-bash", "tree-sitter-c", diff --git a/Cargo.toml b/Cargo.toml index 4ec5f77..7878d54 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ ignore = "0.4" ratatui = { version = "0.30", default-features = false, features = ["crossterm_0_29", "layout-cache", "underline-color", "unstable-backend-writer"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +similar = { version = "2", default-features = false } tree-sitter = "0.27.1" tree-sitter-bash = "0.25.1" tree-sitter-c = "0.24.2" diff --git a/README.md b/README.md index 085ba3d..955983f 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ Vamp starts in Normal mode. Press `i`, type text, then Esc to return to Normal. | Paste in Normal | `p`/`P` after/before the caret or row for linewise fragments; system clipboard by default | | Command palette | Ctrl+p; type to filter, arrows select, Enter accepts, Esc cancels | | Floating/docked explorer in Normal or Visual | Space then `o` / Space then `e` | -| Repository diff in Normal | `:diff`; select Before then After, Tab changes category, type to search | +| Repository diff in Normal | `:diff`; choose Before then After, Tab changes category, type to search; Working checkout After is editable; `:diff.close` exits | Space is the default leader: press its suffix keys in order, not simultaneously. The [editing guide](docs/editing.md) contains the complete key table and behavior reference. diff --git a/docs/configuration.md b/docs/configuration.md index 145c2ff..97c39da 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -78,6 +78,7 @@ Normal `dd` and `cc` delete/change complete rows, with leading counts. Bind `con | Normal | `edit`, `buffers`, `buffer`, `buffer_next`, `buffer_previous`, `buffer_delete`, `buffer_force_delete` | | Normal | `follow_link` | | Normal | `diff` | +| Normal, Insert, Visual, Command, Search | `diff.close` | | Normal | `jump_back`, `jump_forward` | | Normal | `lsp.definition`, `lsp.diagnostics`, `lsp.hover` | | Insert | `lsp.completion`, `snippet.next`, `snippet.previous` | @@ -139,9 +140,13 @@ config.bind "ctrl+f", config.core.right, mode: config.modes.tree config.bind "ctrl+h", nil, mode: config.modes.tree ``` -The diff's right-docked changed-file explorer uses these same tree and selection contexts, including remaps and disabled keys. Enter opens the selected file's diff rather than an editable buffer; its catalog comes from the comparison, not a disk scan. +The diff's changed-file explorer and floating picker use these same tree and selection contexts, including remaps and disabled keys. Enter opens the selected file's comparison; Working checkout After reuses its editable buffer. The catalog contains only changed paths, with A/M/D/R status markers, rather than a disk scan. -In the diff view, Ctrl+e is reserved for hiding/showing the changed-file explorer and takes precedence over tree/selection remaps. Its query, selection, and expansion state survive hiding; the panes use the reclaimed width. +Ordinary `config.core.file_picker` and `config.core.file_explorer` bindings work inside comparisons. Ctrl+w closes a focused picker/explorer, retaining its query, selection, and expansion state; the panes use the reclaimed width. Ctrl+e has no reserved diff behavior. Close the comparison explicitly with `:diff.close`, its palette action, or the generated action value below. `config.core.diff` remains the opening action and also exposes its immutable `close` child; Escape never closes a comparison. + +```ruby +config.bind "leader d q", config.core.diff.close +``` Ctrl+h is **not** a Backspace alias or default deletion key in any mode. Raw Ctrl+h (0x08) and Backspace (DEL/0x7f) are distinct, as are CSI-u events. In the tree it navigates in NORMAL and INSERT without deleting query text; it never deletes file-buffer Insert text by default. Explicit `config.core.backspace` bindings remain available if desired. diff --git a/docs/editing.md b/docs/editing.md index 4b3286a..b3a8052 100644 --- a/docs/editing.md +++ b/docs/editing.md @@ -197,7 +197,7 @@ The project root is the working directory captured at startup. Scanning uses the Run `:diff` in Normal mode, choose **Before**, then **After**, and press Enter to compare. The command palette also exposes **Diff**; there is no default keybinding. Repository discovery starts at the active file's directory, or the startup project root for an unnamed buffer, and prefers jj over Git. -The floating selector has **Revisions / Bookmarks** tabs for jj, or **Commits / Branches / Tags** for Git. Type to filter by revision/change ID, commit ID, reference name, or description. **Working checkout** stays first in every category, even when it does not match the query. It compares files on disk, including eligible untracked files; it neither saves nor includes unsaved editor text. +The floating selector has **Revisions / Bookmarks** tabs for jj, or **Commits / Branches / Tags** for Git. Type to filter by revision/change ID, commit ID, reference name, or description. **Working checkout** stays first in every category, even when it does not match the query. As Before it is a disk snapshot, including eligible untracked files. As After it includes unsaved regular-text buffers and enables live editing; opening a comparison never saves them. | Selector input | Effect | | --- | --- | @@ -205,27 +205,27 @@ The floating selector has **Revisions / Bookmarks** tabs for jj, or **Commits / | Up / Down, Ctrl+k / Ctrl+j | Previous / next entry | | Backspace / Ctrl+u | Remove a query grapheme / clear query | | Enter | Choose the entry | -| Esc | After → return to Before; Before → close | +| Esc | Cancel loading; After → return to Before; Before → clear query without closing | 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 read-only comparison has Before on the left, After on the right, and the changed-file explorer **docked on the right**. The explorer reuses the normal compact directory tree, filtering, and configurable `tree`/`selection` navigation. Its catalog contains only changed paths, including removed and historical files; it does not scan the working directory. Use h/j/k/l or control/arrow navigation, Enter to toggle a folder or open a file, `i` to filter, Ctrl+u to clear, and Ctrl+t to switch Tree/List. Opening a file focuses Before. +The comparison has Before on the left, After on the right, and the changed-file explorer **docked on the right**. 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, binary files, symlinks, and submodules remain read-only. Closing the diff keeps its buffers and unsaved edits. -**Ctrl+e** hides/shows the explorer in the diff view. Hiding reclaims its entire width for the split panes without losing the filter, selected row, folder expansion, displayed file, or pane scroll offsets. Hiding from Files moves focus to Before; showing restores Files focus. While hidden, Tab/Shift+Tab cycles only between Before and After. Ctrl+e is a reserved diff-view control and takes precedence over tree/selection remaps. +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 Before. While the explorer is hidden, Tab/Shift+Tab cycles only between the two text panes. File rows show colored **A** (added, green), **M** (modified, yellow), **D** (removed, red), or **R** (renamed, cyan) markers in Tree and List views, including filtered results. Rename rows show the new name followed by `← old/path`; directories have no file-status marker. Ordinary file exploration remains unannotated. 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. -With a text pane focused, **`[` expands above** and **`]` expands below** the excerpt at the top of the viewport, revealing up to **15 lines** per keypress. 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. +In **Normal mode** with a text pane focused, **`[` expands above** and **`]` expands below** the excerpt at the top of the viewport, revealing up to **15 lines** per keypress. In editable After's Insert mode these keys insert literal text. 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. Configure these counts in Ruby with `config.diff_context_lines = 10` (nonnegative; `0` shows changes only) and `config.diff_expand_lines = 15` (positive). -Changed text rows use muted red backgrounds in Before and muted green backgrounds in After, including blank tails and real empty lines. 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). Unknown languages retain the normal foreground; historical snapshots do not borrow live-buffer LSP tokens. +Changed text rows use muted red backgrounds in Before and muted green backgrounds in After, including blank tails and real empty lines. 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. 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. -Tab/Shift+Tab cycles focus between Files, Before, and After. In text panes, Up/Down or Ctrl+j/k scrolls both panes together; Left/Right or Ctrl+h/l scrolls the focused pane horizontally. Home/End goes to the first/last tree row or displayed excerpt/hidden-line row. Wheel input over a text pane scrolls both panes; over Files it navigates visible tree rows without opening them. Esc first leaves filtering when the tree is focused; otherwise it closes the diff and preserves the editor's buffers, cursors, unsaved text, and undo history. Typing and pasted text cannot edit either snapshot. +In Normal mode, Tab/Shift+Tab cycles focus between Files, Before, and After. 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 both 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 Before; **it never closes the diff**. Use **`:diff.close`**, the palette's **Close diff** action, 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 63147e6..c8d6855 100644 --- a/src/diff.rs +++ b/src/diff.rs @@ -1,6 +1,9 @@ use crate::editor::Input; -use crate::files::{FilePicker, FileView}; -use std::path::PathBuf; +use crate::files::{FilePicker, FilePresentation, FileView}; +use std::{ + collections::BTreeSet, + path::{Path, PathBuf}, +}; use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthStr; @@ -77,6 +80,8 @@ pub struct ChangedFile { pub status: FileStatus, pub before: Vec, pub after: Vec, + pub before_eof: bool, + pub after_eof: bool, pub hunks: Vec, pub notice: Option, } @@ -85,12 +90,18 @@ pub struct Comparison { pub before: String, pub after: String, pub files: Vec, + pub root: PathBuf, + pub editable_paths: BTreeSet, } #[derive(Debug)] pub enum Operation { Discover, FetchRemote(String), - Compare { before: Revision, after: Revision }, + Compare { + before: Revision, + after: Revision, + buffers: Box<[(PathBuf, String)]>, + }, } #[derive(Debug)] pub enum Outcome { @@ -164,6 +175,10 @@ pub struct DiffState { pub rows: Vec, pub display_rows: Vec, excerpt_ranges: Vec<(usize, usize)>, + pub live_buffer: Option, + pub live_revision: Option, + pub follow_caret: bool, + live_files: BTreeSet, context_lines: usize, expand_lines: usize, pending: Option, @@ -199,6 +214,10 @@ impl DiffState { rows: Vec::new(), display_rows: Vec::new(), excerpt_ranges: Vec::new(), + live_buffer: None, + live_revision: None, + follow_caret: false, + live_files: BTreeSet::new(), context_lines: 10, expand_lines: 15, pending: Some(Operation::Discover), @@ -261,9 +280,17 @@ impl DiffState { self.error = None; } Ok(Outcome::Comparison(comparison)) if expected == Some(RequestKind::Comparison) => { + self.live_files.clear(); + self.live_buffer = None; + self.live_revision = None; + self.follow_caret = false; let mut files = FilePicker::from_paths( - PathBuf::new(), - comparison.files.iter().map(|file| file.path.clone()), + comparison.root.clone(), + comparison + .files + .iter() + .enumerate() + .map(|(index, file)| (index, file.path.clone())), ); if let Some(file) = comparison.files.first() { files.reveal_path(&file.path); @@ -351,8 +378,8 @@ impl DiffState { } } - /// Returns true only when the modal should close. All other inputs are consumed. - pub(crate) fn handle(&mut self, input: Input) -> bool { + /// Handles modal input; closing is an explicit editor action. + pub(crate) fn handle(&mut self, input: Input) { if input == Input::Escape { if self.phase == Phase::View && self.focus == Focus::Files @@ -361,28 +388,40 @@ impl DiffState { && files.view == FileView::Tree { files.finish_search(); - return false; + return; } - if self.phase != Phase::After { - return true; + if self.phase == Phase::View { + if self.focus == Focus::Files + && let Some(files) = &mut self.files + { + files.focused = false; + self.files_visible = files.presentation == FilePresentation::Docked; + self.focus = Focus::Before; + } + return; } self.pending = None; self.request_kind = None; self.loading = false; + self.error = None; + if self.phase == Phase::Before { + self.query.clear(); + self.refilter(); + return; + } self.phase = Phase::Before; self.after = None; - self.error = None; if let Some(position) = self.before_position.take() { self.category = position.category; self.query = position.query; self.refilter(); self.select_target(&position.selected); } - return false; + return; } if self.phase == Phase::View { self.handle_view(input); - return false; + return; } match input { Input::Char('\t') | Input::BackTab => { @@ -417,7 +456,6 @@ impl DiffState { } _ => {} } - false } fn choose(&mut self) { @@ -448,23 +486,12 @@ impl DiffState { self.queue(Operation::Compare { before: before.clone(), after: revision, + buffers: Box::new([]), }); } } fn handle_view(&mut self, input: Input) { - if input == Input::Ctrl('e') { - self.files_visible = !self.files_visible; - if self.files_visible { - self.focus = Focus::Files; - } else if self.focus == Focus::Files { - self.focus = Focus::Before; - } - if let Some(files) = &mut self.files { - files.focused = self.files_visible && self.focus == Focus::Files; - } - return; - } if self.focus == Focus::Files { let files = self.files.as_mut().expect("comparison file picker"); match input { @@ -500,14 +527,12 @@ impl DiffState { } Input::Enter => { if let Some(path) = files.accept() - && let Some(index) = self.comparison.as_ref().and_then(|comparison| { - comparison.files.iter().position(|file| file.path == path) - }) + && self.select_file(&path) { - self.file = index; self.focus = Focus::Before; - self.files.as_mut().expect("comparison picker").focused = false; - self.align_file(); + let files = self.files.as_mut().expect("comparison picker"); + files.focused = false; + self.files_visible = files.presentation == FilePresentation::Docked; } return; } @@ -566,14 +591,213 @@ impl DiffState { } } + pub fn after_editable(&self) -> bool { + self.phase == Phase::View + && self + .after + .as_ref() + .is_some_and(|revision| revision.target == RevisionTarget::Working) + && self.comparison.as_ref().is_some_and(|comparison| { + comparison + .files + .get(self.file) + .is_some_and(|file| comparison.editable_paths.contains(&file.path)) + }) + } + + pub(crate) fn select_file(&mut self, path: &Path) -> bool { + let Some(index) = self.comparison.as_ref().and_then(|comparison| { + let relative = path.strip_prefix(&comparison.root).unwrap_or(path); + comparison + .files + .iter() + .position(|file| file.path == relative) + }) else { + return false; + }; + self.file = index; + self.live_buffer = None; + self.live_revision = None; + self.follow_caret = false; + self.align_file(); + true + } + + pub fn after_display_row(&self, source_row: usize) -> Option { + self.display_rows.iter().position(|display| { + matches!(*display, DisplayRow::Line(index) if self.rows[index].after == Some(source_row)) + }) + } + + pub fn reveal_after(&mut self, caret_row: usize) { + let Some(index) = self + .rows + .iter() + .position(|row| row.after == Some(caret_row)) + else { + return; + }; + if self.after_display_row(caret_row).is_some() { + return; + } + let anchor = self.display_rows.get(self.top).copied(); + self.excerpt_ranges.push(( + index.saturating_sub(self.context_lines), + index + .saturating_add(self.context_lines) + .saturating_add(1) + .min(self.rows.len()), + )); + self.excerpt_ranges.sort_unstable(); + self.rebuild_display_rows(); + self.restore_top(anchor); + } + + fn restore_top(&mut self, anchor: Option) { + if let Some(anchor) = anchor { + let index = match anchor { + DisplayRow::Line(index) => index, + DisplayRow::Fold { start, .. } => start, + }; + self.top = self + .display_rows + .iter() + .position(|row| match *row { + DisplayRow::Line(row) => row == index, + DisplayRow::Fold { start, end } => start <= index && index < end, + }) + .unwrap_or(0); + } + } + + pub fn refresh_after(&mut self, lines: &[String], revision: u64, caret_row: usize) { + if !self.after_editable() { + return; + } + if self.live_revision == Some(revision) { + self.reveal_after(caret_row); + return; + } + let file = &self.comparison.as_ref().unwrap().files[self.file]; + let was_live = self.live_files.contains(&self.file); + let mut old = file.after.iter().map(String::as_str).collect::>(); + if !was_live && (file.after_eof || old.is_empty()) { + old.push(""); + } + let new = lines.iter().map(String::as_str).collect::>(); + let ops = similar::capture_diff_slices(similar::Algorithm::Myers, &old, &new); + let mut mapping = vec![new.len().saturating_sub(1); old.len()]; + for op in &ops { + let (_, old_range, new_range) = op.as_tag_tuple(); + for offset in 0..old_range.len() { + mapping[old_range.start + offset] = (new_range.start + + offset.min(new_range.len().saturating_sub(1))) + .min(new.len().saturating_sub(1)); + } + } + let map_after = |index: usize| mapping.get(index).copied().unwrap_or(0); + let mut before_visible = BTreeSet::new(); + let mut after_visible = BTreeSet::new(); + for &(start, end) in &self.excerpt_ranges { + for row in &self.rows[start..end] { + before_visible.extend(row.before); + after_visible.extend(row.after.map(map_after)); + } + } + let top_before = self + .display_rows + .get(self.top) + .and_then(|display| match *display { + DisplayRow::Line(index) => self.rows[index].before, + DisplayRow::Fold { start, .. } => self.rows[start].before, + }); + let top_after = self + .display_rows + .get(self.top) + .and_then(|display| match *display { + DisplayRow::Line(index) => self.rows[index].after, + DisplayRow::Fold { start, .. } => self.rows[start].after, + }) + .map(map_after); + let was_changed = file_is_changed(file); + let file = &mut self.comparison.as_mut().unwrap().files[self.file]; + file.after.resize_with(lines.len(), String::new); + for (target, line) in file.after.iter_mut().zip(lines) { + target.clone_from(line); + } + file.after_eof = lines.len() > 1 && lines.last().is_some_and(String::is_empty); + file.hunks = line_hunks( + &file.before, + file.before_eof, + live_content(lines, file.after_eof), + file.after_eof, + ); + update_eof_notice(file); + let changed = file_is_changed(file); + self.live_files.insert(self.file); + self.live_revision = Some(revision); + let left_before = self.left_before; + let left_after = self.left_after; + self.rebuild_file(false); + self.left_before = left_before.min(self.max_before); + self.left_after = left_after.min(self.max_after); + for (index, row) in self.rows.iter().enumerate() { + if row + .before + .is_some_and(|index| before_visible.contains(&index)) + || row + .after + .is_some_and(|index| after_visible.contains(&index)) + { + self.excerpt_ranges.push((index, index + 1)); + } + } + self.excerpt_ranges.sort_unstable(); + self.rebuild_display_rows(); + if let Some(row) = top_after { + self.top = self.after_display_row(row).unwrap_or(0); + } else if let Some(source) = top_before + && let Some(index) = self.rows.iter().position(|row| row.before == Some(source)) + { + self.restore_top(Some(DisplayRow::Line(index))); + } + self.reveal_after(caret_row); + if was_changed != changed { + self.refresh_files(); + } + } + + fn refresh_files(&mut self) { + let Some(comparison) = &self.comparison else { + return; + }; + let paths = comparison + .files + .iter() + .enumerate() + .filter(|(_, file)| file_is_changed(file)) + .map(|(index, file)| (index, file.path.clone())); + if let Some(files) = &mut self.files { + files.replace_paths(paths); + } else { + self.files = Some(FilePicker::from_paths(comparison.root.clone(), paths)); + } + } + fn align_file(&mut self) { + self.rebuild_file(true); + } + + fn rebuild_file(&mut self, reset: bool) { self.rows.clear(); self.display_rows.clear(); self.excerpt_ranges.clear(); - self.top = 0; - self.left_before = 0; - self.left_after = 0; - self.max_before = 0; + if reset { + self.top = 0; + self.left_before = 0; + self.left_after = 0; + self.max_before = 0; + } self.max_after = 0; let Some(file) = self .comparison @@ -598,15 +822,22 @@ impl DiffState { .max() .unwrap_or(0) }; - self.max_before = width(&file.before); + if reset { + self.max_before = width(&file.before); + } self.max_after = width(&file.after); + let after_len = if self.live_files.contains(&self.file) { + live_content(&file.after, file.after_eof).len() + } else { + file.after.len() + }; self.rows.reserve(file.before.len().max(file.after.len())); let mut before = 0; let mut after = 0; for hunk in &file.hunks { // Clamp invalid ranges rather than indexing historical text unchecked. let start_before = hunk.before_start.min(file.before.len()).max(before); - let start_after = hunk.after_start.min(file.after.len()).max(after); + let start_after = hunk.after_start.min(after_len).max(after); append_rows( &mut self.rows, before, @@ -620,7 +851,7 @@ impl DiffState { let end_before = before .saturating_add(hunk.before_len) .min(file.before.len()); - let end_after = after.saturating_add(hunk.after_len).min(file.after.len()); + let end_after = after.saturating_add(hunk.after_len).min(after_len); append_rows(&mut self.rows, before, end_before, after, end_after, true); before = end_before; after = end_after; @@ -630,9 +861,16 @@ impl DiffState { before, file.before.len(), after, - file.after.len(), + after_len, false, ); + if after_len < file.after.len() { + self.rows.push(AlignedRow { + before: None, + after: Some(after_len), + kind: RowKind::Equal, + }); + } self.reset_excerpts(); } @@ -781,6 +1019,89 @@ fn append_rows( } } +fn live_content(lines: &[String], eof: bool) -> &[String] { + if eof || (lines.len() == 1 && lines[0].is_empty()) { + &lines[..lines.len().saturating_sub(1)] + } else { + lines + } +} + +pub(crate) fn line_hunks( + before: &[String], + before_eof: bool, + after: &[String], + after_eof: bool, +) -> Vec { + fn tokens(lines: &[String], eof: bool) -> Vec<(&str, bool)> { + lines + .iter() + .enumerate() + .map(|(index, line)| (line.as_str(), index + 1 < lines.len() || eof)) + .collect() + } + let before = tokens(before, before_eof); + let after = tokens(after, after_eof); + similar::capture_diff_slices(similar::Algorithm::Myers, &before, &after) + .into_iter() + .filter_map(|op| { + let (tag, before, after) = op.as_tag_tuple(); + (tag != similar::DiffTag::Equal).then_some(Hunk { + before_start: before.start, + before_len: before.len(), + after_start: after.start, + after_len: after.len(), + }) + }) + .collect() +} + +fn file_is_changed(file: &ChangedFile) -> bool { + file.status != FileStatus::Modified + || !file.hunks.is_empty() + || file.notice.as_ref().is_some_and(|notice| { + notice + .split("; ") + .any(|part| !part.starts_with("EOF newline:")) + }) +} + +fn update_eof_notice(file: &mut ChangedFile) { + let mut notices = file + .notice + .as_deref() + .unwrap_or("") + .split("; ") + .filter(|part| !part.is_empty() && !part.starts_with("EOF newline:")) + .map(str::to_owned) + .collect::>(); + let before_empty = file.before.is_empty(); + let after_empty = live_content(&file.after, file.after_eof).is_empty(); + if (!before_empty && !file.before_eof) || (!after_empty && !file.after_eof) { + let label = |absent, empty, eof| { + if absent { + "absent" + } else if empty { + "empty file" + } else if eof { + "present" + } else { + "missing" + } + }; + notices.push(format!( + "EOF newline: Before {}, After {}", + label( + file.status == FileStatus::Added, + before_empty, + file.before_eof + ), + label(false, after_empty, file.after_eof) + )); + } + file.notice = (!notices.is_empty()).then(|| notices.join("; ")); +} + #[cfg(test)] mod tests { use super::*; @@ -1079,7 +1400,7 @@ mod tests { editor.handle(Input::Enter); let (id, operation) = editor.take_diff_request().unwrap(); match operation { - Operation::Compare { before, after } => { + Operation::Compare { before, after, .. } => { assert_eq!(before.target, RevisionTarget::Commit("abcdef123".into())); assert_eq!(after.target, RevisionTarget::Working); } @@ -1092,6 +1413,8 @@ mod tests { Comparison { before: "abcdef123".into(), after: "Working checkout".into(), + root: PathBuf::new(), + editable_paths: BTreeSet::new(), files: vec![ ChangedFile { path: "renamed.txt".into(), @@ -1111,6 +1434,8 @@ mod tests { "tail".into(), "end".into(), ], + before_eof: false, + after_eof: false, hunks: vec![ Hunk { before_start: 1, @@ -1133,6 +1458,8 @@ mod tests { status: FileStatus::Added, before: vec![], after: vec!["a".into(), "b".into()], + before_eof: false, + after_eof: false, hunks: vec![Hunk { before_start: 0, before_len: 0, @@ -1147,6 +1474,8 @@ mod tests { status: FileStatus::Removed, before: vec![], after: vec![], + before_eof: false, + after_eof: false, hunks: vec![], notice: Some("Binary file".into()), }, @@ -1261,6 +1590,9 @@ mod tests { assert_eq!(editor.diff().unwrap().phase, Phase::After); editor.handle(Input::Escape); editor.handle(Input::Escape); + assert_eq!(editor.diff().unwrap().phase, Phase::Before); + assert!(editor.diff().unwrap().query.is_empty()); + editor.execute("diff.close"); assert!(editor.diff().is_none()); } @@ -1362,129 +1694,55 @@ mod tests { assert_eq!(editor.carets(), carets); assert_eq!(editor.text_revision(), revision); editor.handle(Input::Escape); + assert!(editor.diff().is_some()); + editor.execute("diff.close"); assert!(editor.diff().is_none()); } #[test] - fn explorer_toggle_preserves_filtered_tree_review_and_source_state() { + fn explorer_actions_preserve_review_and_ctrl_e_uses_normal_bindings() { let mut editor = editor(); - editor - .apply(vec![Effect::Replace("unsaved source".into())]) - .unwrap(); - let original = editor.context().text; - let carets = editor.carets(); - let revision = editor.text_revision(); open(&mut editor); let id = choose_pair(&mut editor); - let mut changes = comparison(); - changes.files[0].path = "src/deep/renamed.txt".into(); - changes.files[1].path = "src/deep/added.txt".into(); - editor.apply_diff_result(id, Ok(Outcome::Comparison(changes))); - editor.handle(Input::Char('\t')); - editor.handle(Input::Down); - editor.handle(Input::Right); - editor.handle(Input::Char('\t')); - editor.handle(Input::Right); - editor.handle(Input::Char('\t')); - editor.handle(Input::Char('i')); - text(&mut editor, "src"); - editor.handle(Input::Down); - let snapshot = |state: &DiffState| { - let files = state.files.as_ref().unwrap(); - ( - files.query.clone(), - files.selected, - files.view, - files.searching, - files - .entries() - .map(|row| (row.path.to_owned(), row.expanded)) - .collect::>(), - ) - }; - let tree = snapshot(editor.diff().unwrap()); - let rows = editor - .diff() - .unwrap() - .rows - .iter() - .map(|row| (row.before, row.after, row.kind)) - .collect::>(); + editor.apply_diff_result(id, Ok(Outcome::Comparison(comparison()))); editor .bind("tree", "ctrl+e", Some("core.down".into())) .unwrap(); - editor - .bind("selection", "ctrl+e", Some("core.up".into())) - .unwrap(); editor.handle(Input::Ctrl('e')); - let state = editor.diff().unwrap(); - assert!(!state.files_visible); - assert_eq!(state.focus, Focus::Before); - assert!(!state.files.as_ref().unwrap().focused); + assert!(editor.diff().unwrap().files_visible); assert_eq!( - (state.file, state.top, state.left_before, state.left_after), - (0, 1, 1, 1) + editor + .diff() + .unwrap() + .files + .as_ref() + .unwrap() + .entries() + .nth(editor.diff().unwrap().files.as_ref().unwrap().selected) + .unwrap() + .path, + Path::new("added.txt") ); - assert_eq!(snapshot(state), tree); - for (input, focus) in [ - (Input::Char('\t'), Focus::After), - (Input::Char('\t'), Focus::Before), - (Input::BackTab, Focus::After), - (Input::BackTab, Focus::Before), - ] { - editor.handle(input); - let state = editor.diff().unwrap(); - assert_eq!(state.focus, focus); - assert!(!state.files.as_ref().unwrap().focused); - } - for input in [ - Input::Char('z'), - Input::Ctrl('t'), - Input::Backspace, - Input::Delete, - Input::Paste("not source".into()), - Input::Enter, - ] { - editor.handle(input); - } - assert_eq!(snapshot(editor.diff().unwrap()), tree); - editor.handle(Input::Down); - assert_eq!(editor.diff().unwrap().top, 2); - editor.handle(Input::Ctrl('e')); - let state = editor.diff().unwrap(); - assert!(state.files_visible && state.files.as_ref().unwrap().focused); - assert_eq!(state.focus, Focus::Files); - assert_eq!(snapshot(state), tree); - assert_eq!( - (state.file, state.top, state.left_before, state.left_after), - (0, 2, 1, 1) - ); - assert_eq!( - state - .rows - .iter() - .map(|row| (row.before, row.after, row.kind)) - .collect::>(), - rows - ); - editor.handle(Input::Char('\t')); - editor.handle(Input::Ctrl('e')); - assert_eq!(editor.diff().unwrap().focus, Focus::Before); - editor.handle(Input::Ctrl('e')); - editor.handle(Input::BackTab); - editor.handle(Input::Ctrl('e')); - assert_eq!(editor.diff().unwrap().focus, Focus::After); - assert!(!editor.diff().unwrap().files.as_ref().unwrap().focused); - editor.handle(Input::Ctrl('e')); + editor.handle(Input::Ctrl('w')); + assert!(editor.diff().is_some()); + assert!(!editor.diff().unwrap().files_visible); + text(&mut editor, " e"); + assert!(editor.diff().unwrap().files_visible); assert_eq!(editor.diff().unwrap().focus, Focus::Files); - assert_eq!(snapshot(editor.diff().unwrap()), tree); - assert_eq!(editor.context().text, original); - assert_eq!(editor.carets(), carets); - assert_eq!(editor.text_revision(), revision); + assert_eq!( + editor.diff().unwrap().files.as_ref().unwrap().presentation, + crate::files::FilePresentation::Docked + ); + editor.handle(Input::Ctrl('w')); + text(&mut editor, " o"); + assert_eq!( + editor.diff().unwrap().files.as_ref().unwrap().presentation, + crate::files::FilePresentation::Floating + ); editor.handle(Input::Escape); - assert!(!editor.diff().unwrap().files.as_ref().unwrap().searching); - editor.handle(Input::Ctrl('e')); editor.handle(Input::Escape); + assert!(editor.diff().is_some()); + editor.execute("diff.close"); assert!(editor.diff().is_none()); } @@ -1584,7 +1842,7 @@ mod tests { let first = choose_pair(&mut editor); assert!(editor.apply_diff_result(first, Ok(Outcome::Comparison(comparison())))); assert_eq!(editor.diff().unwrap().comparison_id, Some(first)); - editor.handle(Input::Escape); + editor.execute("diff.close"); open(&mut editor); assert_eq!(editor.diff().unwrap().comparison_id, None); let second = choose_pair(&mut editor); @@ -1628,7 +1886,7 @@ mod tests { } #[test] - fn native_and_ex_diff_preserve_dirty_buffers_carets_and_undo_and_gate_all_edits() { + fn immutable_diff_and_selectors_preserve_dirty_buffers_carets_and_undo() { let mut editor = editor(); editor .apply(vec![Effect::Replace("original\\nsecond".into())]) @@ -1671,6 +1929,8 @@ mod tests { buffers ); editor.handle(Input::Escape); + assert!(editor.diff().is_some()); + editor.execute("diff.close"); assert_eq!(editor.mode(), Mode::Normal); open(&mut editor); let id = choose_pair(&mut editor); @@ -1690,6 +1950,8 @@ mod tests { assert_eq!(editor.carets(), carets); editor.handle(Input::Escape); editor.handle(Input::Escape); + assert!(editor.diff().is_some()); + editor.execute("diff.close"); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "original\\nsecond"); } @@ -1724,6 +1986,8 @@ mod tests { before: "Working".into(), after: "Working".into(), files: vec![], + root: PathBuf::new(), + editable_paths: BTreeSet::new(), })) )); assert_eq!(editor.diff().unwrap().phase, Phase::View); @@ -1734,6 +1998,121 @@ mod tests { assert_eq!(editor.diff().unwrap().top, 0); assert_eq!(editor.diff().unwrap().file, 0); editor.handle(Input::Escape); + assert!(editor.diff().is_some()); + editor.execute("diff.close"); assert!(editor.diff().is_none()); } + + fn live_state(before: Vec, after: Vec, eof: bool) -> DiffState { + let mut state = DiffState::new(); + state.phase = Phase::View; + state.focus = Focus::After; + state.after = Some(revision( + RevisionTarget::Working, + "Working", + "", + Category::Commits, + )); + state.comparison = Some(Comparison { + root: PathBuf::new(), + before: "base".into(), + after: "Working".into(), + editable_paths: BTreeSet::from([PathBuf::from("file")]), + files: vec![ChangedFile { + path: "file".into(), + old_path: None, + status: FileStatus::Modified, + hunks: line_hunks(&before, eof, &after, eof), + before, + after, + before_eof: eof, + after_eof: eof, + notice: None, + }], + }); + state.files = Some(FilePicker::from_paths( + PathBuf::new(), + [(0, PathBuf::from("file"))], + )); + state.align_file(); + state + } + + #[test] + fn live_reflow_keeps_separate_hunks_expansion_and_caret_after_revert() { + let base = (0..100) + .map(|row| format!("line {row}")) + .collect::>(); + let mut changed = base.clone(); + changed[30] = "first change".into(); + changed[70] = "second change".into(); + let mut state = live_state(base.clone(), changed.clone(), true); + state.set_context_options(2, 5); + changed.push(String::new()); + state.refresh_after(&changed, 1, 30); + assert_eq!(state.comparison.as_ref().unwrap().files[0].hunks.len(), 2); + assert!( + state + .rows + .last() + .is_some_and(|row| row.after == Some(100) && row.kind == RowKind::Equal) + ); + state.top = state.after_display_row(30).unwrap(); + state.handle(Input::Char('[')); + assert!(state.after_display_row(23).is_some()); + changed.insert(10, "inserted outside excerpts".into()); + state.refresh_after(&changed, 2, 31); + assert!(state.after_display_row(24).is_some()); + assert_eq!(state.comparison.as_ref().unwrap().files[0].hunks.len(), 3); + assert!( + state.display_rows.iter().any( + |row| matches!(row, DisplayRow::Fold { start, end } if *start < 60 && *end > 40) + ) + ); + let mut reverted = base; + reverted.push(String::new()); + state.refresh_after(&reverted, 3, 90); + assert!(state.comparison.as_ref().unwrap().files[0].hunks.is_empty()); + assert!(state.rows.iter().all(|row| row.kind == RowKind::Equal)); + assert!(state.after_display_row(90).is_some()); + assert_eq!(state.files.as_ref().unwrap().entries().len(), 0); + assert_eq!(state.file, 0); + reverted[50] = "changed again".into(); + state.refresh_after(&reverted, 4, 50); + assert_eq!(state.files.as_ref().unwrap().entries().len(), 1); + assert!(state.after_display_row(50).is_some()); + } + + #[test] + fn live_empty_and_eof_rows_match_buffer_without_false_eof_highlights() { + let mut state = live_state(vec!["same".into()], vec!["changed".into()], true); + state.refresh_after(&["same".into(), "".into()], 1, 1); + assert!(state.comparison.as_ref().unwrap().files[0].hunks.is_empty()); + assert_eq!(state.rows.len(), 2); + assert!(state.rows.iter().all(|row| row.kind == RowKind::Equal)); + assert!(state.after_display_row(1).is_some()); + state.refresh_after(&["same".into()], 2, 0); + assert_eq!(state.rows[0].kind, RowKind::Changed); + assert!( + state.comparison.as_ref().unwrap().files[0] + .notice + .as_ref() + .unwrap() + .contains("After missing") + ); + state.refresh_after(&["".into()], 3, 0); + assert!(state.after_display_row(0).is_some()); + assert_eq!(state.comparison.as_ref().unwrap().files[0].after, [""]); + let mut empty = live_state(vec![], vec!["text".into()], false); + empty.refresh_after(&["".into()], 1, 0); + assert!(empty.comparison.as_ref().unwrap().files[0].hunks.is_empty()); + assert_eq!(empty.rows.len(), 1); + assert_eq!(empty.rows[0].kind, RowKind::Equal); + assert_eq!(empty.after_display_row(0), Some(0)); + empty.after.as_mut().unwrap().target = RevisionTarget::Commit("historic".into()); + empty.refresh_after(&["must not apply".into()], 2, 0); + assert_eq!(empty.comparison.as_ref().unwrap().files[0].after, [""]); + empty.handle(Input::Escape); + assert_eq!(empty.phase, Phase::View); + } } diff --git a/src/editor.rs b/src/editor.rs index b1368e4..206fa71 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -1,5 +1,5 @@ use crate::clipboard::{ClipboardMode, SystemClipboard}; -use crate::diff::{DiffState, Operation, Outcome, Phase}; +use crate::diff::{DiffState, Focus, Operation, Outcome, Phase, RevisionTarget}; use crate::files::{DockSide, FilePicker, FilePresentation, FileView}; use crate::plugin::{CompletionItem, Diagnostic, SemanticToken, SnippetTabstop, TextPosition}; use serde::Serialize; @@ -555,6 +555,18 @@ core_actions![ ), ("core.save", Action::Save, &[Mode::Normal], "w"), ("core.diff", Action::Diff, &[Mode::Normal], "diff"), + ( + "core.diff.close", + Action::DiffClose, + &[ + Mode::Normal, + Mode::Insert, + Mode::Visual, + Mode::Command, + Mode::Search + ], + "diff.close", + ), ("core.quit", Action::Quit, &[Mode::Normal], "q"), ( "core.force_quit", @@ -1064,6 +1076,7 @@ pub struct Editor { file_explorer_view: FileView, count: Option, diff: Option, + diff_prompt_mode: Option, diff_context_options: (usize, usize), next_diff_request: u64, outstanding_diff_request: Option, @@ -1135,6 +1148,7 @@ impl Editor { file_explorer_view: FileView::Tree, count: None, diff: None, + diff_prompt_mode: None, diff_context_options: (10, 15), next_diff_request: 0, outstanding_diff_request: None, @@ -1250,7 +1264,21 @@ impl Editor { } pub(crate) fn take_diff_request(&mut self) -> Option<(u64, Operation)> { - let operation = self.diff.as_mut()?.take_request()?; + let mut operation = self.diff.as_mut()?.take_request()?; + if let Operation::Compare { after, buffers, .. } = &mut operation + && after.target == RevisionTarget::Working + { + *buffers = std::iter::once(&self.active) + .chain(self.parked.values()) + .filter(|buffer| buffer.dirty()) + .filter_map(|buffer| { + let path = buffer.identity.as_ref()?; + let metadata = fs::symlink_metadata(buffer.path.as_ref()?).ok()?; + (metadata.is_file() && fs::read_to_string(path).is_ok()) + .then(|| (path.clone(), buffer.lines.join("\n"))) + }) + .collect(); + } self.next_diff_request = self .next_diff_request .checked_add(1) @@ -1269,6 +1297,11 @@ impl Editor { return false; }; diff.apply_result(id, result); + if let Some(files) = &mut diff.files { + files.set_view(self.file_explorer_view); + } + self.bind_diff_buffer(); + self.refresh_live_diff(); true } @@ -1289,41 +1322,236 @@ impl Editor { self.diff = Some(diff); } - fn handle_diff(&mut self, input: Input) { - // Modal controls take precedence over configured browsing bindings. - let toggle_files = input == Input::Ctrl('e') + fn binding_mode(&self) -> Mode { + if self.diff.is_some() + && !self.diff_editing() + && !matches!(self.active.mode, Mode::Command | Mode::Search) + { + Mode::Normal + } else { + self.active.mode + } + } + + pub fn diff_editable(&self) -> bool { + self.diff + .as_ref() + .is_some_and(|diff| diff.after_editable() && diff.live_buffer == Some(self.active.id)) + } + + pub fn diff_editing(&self) -> bool { + self.diff_editable() && self .diff .as_ref() - .is_some_and(|diff| diff.phase == Phase::View); - if !toggle_files && !matches!(input, Input::Escape | Input::Char('\t') | Input::BackTab) { - if self.handle_tree_binding(&input) { - return; + .is_some_and(|diff| diff.focus == Focus::After) + } + + 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() { + return; + } + if self.diff_editable() { + 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 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 diff = self.diff.as_mut().expect("diff remains open"); + if let Some(comparison) = &mut diff.comparison { + comparison + .editable_paths + .remove(&comparison.files[diff.file].path); + } + diff.live_buffer = None; + diff.live_revision = None; + diff.error = Some(format!("Open failed: {error}")); + } + } + } + + pub(crate) fn refresh_live_diff(&mut self) { + let row = self.cursor().row; + 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) { + diff.refresh_after(&self.active.lines, self.active.text_revision, row); + diff.follow_caret = true; + } + diff.reveal_after(row); + } + + pub(crate) fn scroll_diff(&mut self, focus: Focus, down: bool) { + if let Some(diff) = &mut self.diff + && diff.phase == Phase::View + { + diff.focus = focus; + diff.follow_caret = false; + if let Some(files) = &mut diff.files { + files.focused = diff.files_visible && focus == Focus::Files; + } + diff.navigate(down); + } + } + + 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())) + { + 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); + } + if files_focused && input == Input::Ctrl('w') { + let diff = self.diff.as_mut().expect("active diff"); + diff.files_visible = false; + diff.focus = Focus::Before; + if let Some(files) = &mut diff.files { + files.focused = false; + } + return None; + } + if files_focused && self.handle_tree_binding(&input) { + return None; + } + if !live { match self.selection_bindings.get(&input) { Some(Binding::Action(Action::Down)) => { self.diff.as_mut().expect("active diff").navigate(true); - return; + return None; } Some(Binding::Action(Action::Up)) => { self.diff.as_mut().expect("active diff").navigate(false); - return; + return None; } - Some(Binding::Disabled) => return, + Some(Binding::Disabled) => return None, _ => {} } } + if files_focused + && matches!(input, Input::Char(ch) if !ch.is_control()) + && self + .diff + .as_ref() + .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); + return None; + } + if self.leader_pending().is_some() + || (phase == Phase::View && input == self.leader) + || self + .bindings + .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 + ) + ))) + }) + { + return self.handle_input(input); + } + if live && !matches!(input, Input::Char('\t' | '[' | ']') | Input::BackTab) { + return self.handle_input(input); + } + 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 + ) + { + return self.handle_input(input); + } let back = input == Input::Escape && self .diff .as_ref() - .is_some_and(|diff| diff.phase == Phase::After); - if self.diff.as_mut().expect("active diff").handle(input) { - self.diff = None; - self.outstanding_diff_request = None; - } else if back { + .is_some_and(|diff| diff.phase != Phase::View); + self.diff.as_mut().expect("active diff").handle(input); + if back { self.outstanding_diff_request = None; } + None } fn buffer(&self, id: usize) -> Option<&Buffer> { @@ -2193,7 +2421,11 @@ impl Editor { } pub fn file_picker(&self) -> Option<&FilePicker> { - self.file_picker.as_ref() + if let Some(diff) = &self.diff { + diff.files.as_ref().filter(|_| diff.files_visible) + } else { + self.file_picker.as_ref() + } } pub fn file_explorer_side(&self) -> DockSide { @@ -2786,7 +3018,7 @@ impl Editor { label, owner: "core".into(), bindings: self.palette_bindings(Some(action), ""), - enabled: modes.contains(&self.active.mode), + enabled: modes.contains(&self.binding_mode()), modes: modes.to_vec(), }); } @@ -2820,6 +3052,7 @@ impl Editor { } fn handle_palette(&mut self, input: Input) -> Option { + let mode = self.binding_mode(); let palette = self.palette.as_mut().expect("open palette receives input"); match input { Input::Escape | Input::Ctrl('p') => self.palette = None, @@ -2828,7 +3061,7 @@ impl Editor { .entries .get(palette.selected) .filter(|entry| entry.enabled)?; - if let Some(action) = Action::named(self.active.mode, &entry.name) { + if let Some(action) = Action::named(mode, &entry.name) { if let Some(core) = CORE_ACTIONS .iter() .find(|core| core.action == action && core.palette_prompt) @@ -2841,7 +3074,9 @@ impl Editor { } self.command_line = format!("{} ", entry.name); self.palette = None; - if self.active.mode == Mode::Insert { + if self.diff.is_some() && !self.diff_editing() { + self.diff_prompt_mode.get_or_insert(self.active.mode); + } else if self.active.mode == Mode::Insert { self.finish_insert(); } self.active.mode = Mode::Command; @@ -2866,6 +3101,27 @@ impl Editor { FilePresentation::Floating => self.file_picker_view, FilePresentation::Docked => self.file_explorer_view, }; + if let Some(diff) = &mut self.diff { + if diff.phase == Phase::View { + let picker = diff.files.as_mut().expect("comparison picker"); + picker.presentation = presentation; + picker.set_view(view); + picker.focused = true; + picker.clear_error(); + if presentation == FilePresentation::Floating + && view == FileView::Tree + && !picker.searching + { + picker.start_search(); + } + diff.files_visible = true; + diff.focus = Focus::Files; + self.palette = None; + self.count = None; + self.clear_leader(); + } + return; + } if let Some(picker) = &mut self.file_picker { picker.presentation = presentation; picker.set_view(view); @@ -2895,6 +3151,19 @@ impl Editor { } fn blur_file_picker(&mut self) { + if let Some(diff) = &mut self.diff + && diff.phase == Phase::View + { + if let Some(picker) = &mut diff.files { + picker.focused = false; + if picker.presentation == FilePresentation::Floating { + diff.files_visible = false; + } + } + if diff.focus == Focus::Files { + diff.focus = Focus::Before; + } + } match self.file_picker.as_mut() { Some(picker) if picker.presentation == FilePresentation::Docked => { picker.focused = false; @@ -2949,11 +3218,25 @@ impl Editor { Input::Ctrl(ch) => control_key(ch), input => input, }; - if self.diff.is_some() { - self.handle_diff(input); - return None; + self.bind_diff_buffer(); + let invocation = if self.diff.is_some() { + self.handle_diff(input) + } else { + self.handle_input(input) + }; + self.bind_diff_buffer(); + self.refresh_live_diff(); + if self.diff_editing() + && let Some(diff) = &mut self.diff + { + diff.follow_caret = true; } - if self.completion.is_some() { + invocation + } + + fn handle_input(&mut self, input: Input) -> Option { + let mode = self.binding_mode(); + if self.completion.is_some() && (self.diff.is_none() || self.diff_editing()) { let request = matches!( self.bindings .get(&self.active.mode) @@ -3060,7 +3343,7 @@ impl Editor { if let Input::Paste(text) = input { self.count = None; self.clear_leader(); - if self.active.mode == Mode::Insert { + if self.active.mode == Mode::Insert && (self.diff.is_none() || self.diff_editing()) { self.paste(&text); } return None; @@ -3096,11 +3379,7 @@ impl Editor { return None; } } - match self - .bindings - .get(&self.active.mode) - .and_then(|keys| keys.get(&input)) - { + match self.bindings.get(&mode).and_then(|keys| keys.get(&input)) { Some(Binding::Action(action)) => return self.act(*action), Some(Binding::Command(command)) => { let command = command.clone(); @@ -3112,11 +3391,11 @@ impl Editor { } None => {} } - if matches!(self.active.mode, Mode::Normal | Mode::Visual) && input == self.leader { + if matches!(mode, Mode::Normal | Mode::Visual) && input == self.leader { append_key(&mut self.leader_display, &input); return None; } - if matches!(self.active.mode, Mode::Normal | Mode::Visual) && input == Input::Char('g') { + if matches!(mode, Mode::Normal | Mode::Visual) && input == Input::Char('g') { self.count = None; self.g_pending = true; return None; @@ -3129,7 +3408,10 @@ impl Editor { } self.count = None; match (self.active.mode, input) { - (Mode::Insert, Input::Char(ch)) if !ch.is_control() || ch == '\t' => { + (Mode::Insert, Input::Char(ch)) + if (self.diff.is_none() || self.diff_editing()) + && (!ch.is_control() || ch == '\t') => + { let mut bytes = [0; 4]; self.type_native(ch.encode_utf8(&mut bytes)); } @@ -3147,7 +3429,7 @@ impl Editor { self.leader_display.push(' '); append_key(&mut self.leader_display, &input); self.leader_suffix.push(input); - let bindings = self.leader_bindings.get(&self.active.mode); + let bindings = self.leader_bindings.get(&self.binding_mode()); match bindings.and_then(|keys| keys.get(self.leader_suffix.as_slice())) { Some(Binding::Action(action)) => return self.act(*action), Some(Binding::Command(command)) => { @@ -3172,13 +3454,76 @@ impl Editor { None } + fn finish_diff_prompt(&mut self) { + if let Some(mode) = self.diff_prompt_mode.take() { + self.active.mode = mode; + self.command_line.clear(); + } + } + fn open_action_prompt(&mut self, entry: &CoreAction) { - self.prepare_command(); + if self.diff.is_some() && !self.diff_editing() { + self.diff_prompt_mode.get_or_insert(self.active.mode); + self.palette = None; + self.count = None; + self.clear_leader(); + } else { + self.prepare_command(); + } self.command_line = format!("{} ", entry.ex.expect("prompt has Ex alias")); self.active.mode = Mode::Command; } fn act(&mut self, action: Action) -> Option { + if action == Action::Normal && self.diff_prompt_mode.is_some() { + self.finish_diff_prompt(); + self.count = None; + 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 entry = action.entry(); if entry.ex.is_some() { if entry.prompt { @@ -3187,18 +3532,22 @@ impl Editor { } return self.execute_action(entry, ""); } - self.finish_completion(); - if !matches!( - action, - Action::SnippetNext - | Action::SnippetPrevious - | Action::Newline - | Action::Backspace - | Action::Delete - ) { + if !read_only { + self.finish_completion(); + } + if !read_only + && !matches!( + action, + Action::SnippetNext + | Action::SnippetPrevious + | Action::Newline + | Action::Backspace + | Action::Delete + ) + { self.snippet = None; } - if !matches!(action, Action::JumpBack | Action::JumpForward) { + if !read_only && !matches!(action, Action::JumpBack | Action::JumpForward) { self.clear_hover(); } let count = self.count.take().unwrap_or(1); @@ -3237,6 +3586,7 @@ impl Editor { | Action::BufferDelete | Action::BufferForceDelete | Action::Diff + | Action::DiffClose | Action::LspDefinition | Action::LspHover | Action::LspDiagnostics => unreachable!("catalog Ex actions dispatch above"), @@ -3324,6 +3674,9 @@ impl Editor { Action::Redo => self.redo(), Action::RepeatLastEdit => self.repeat_last_edit(), Action::Command | Action::Search => { + if read_only { + self.diff_prompt_mode.get_or_insert(self.active.mode); + } self.active.mode = if matches!(action, Action::Search) { Mode::Search } else { @@ -3750,9 +4103,13 @@ impl Editor { } pub fn execute(&mut self, command: &str) -> Option { - if self.diff.is_some() { - return None; - } + self.finish_diff_prompt(); + let invocation = self.execute_command(command); + self.refresh_live_diff(); + invocation + } + + fn execute_command(&mut self, command: &str) -> Option { let command = command.trim().strip_prefix(':').unwrap_or(command.trim()); let (name, args) = command .split_once(char::is_whitespace) @@ -3761,8 +4118,15 @@ impl Editor { if let Some(entry) = CORE_ACTIONS.iter().find(|entry| entry.ex == Some(name)) { return self.execute_action(entry, args); } - self.prepare_command(); - self.finish_command_mode(); + if self.diff.is_some() && !self.diff_editing() { + self.count = None; + self.clear_leader(); + self.palette = None; + self.command_line.clear(); + } else { + self.prepare_command(); + self.finish_command_mode(); + } let Some(feature) = self.owner(name) else { self.set_message(format!("Unknown command: {name}")); return None; @@ -3794,6 +4158,33 @@ impl Editor { } fn execute_action(&mut self, entry: &CoreAction, args: &str) -> Option { + self.finish_diff_prompt(); + if entry.action == Action::DiffClose { + if args.is_empty() { + self.diff = None; + self.outstanding_diff_request = None; + self.palette = None; + self.count = None; + self.clear_leader(); + if self.active.mode == Mode::Command { + self.command_line.clear(); + self.active.mode = Mode::Normal; + } + } else { + self.set_message("The diff.close command takes no arguments"); + } + return None; + } + if self.diff.is_some() + && !self.diff_editing() + && !matches!( + entry.action, + Action::Diff | Action::Quit | Action::ForceQuit | Action::Plugins | Action::Buffers + ) + { + self.set_message("Diff pane is read-only"); + return None; + } if entry.action == Action::Diff { if args.is_empty() { self.open_diff(); @@ -3802,7 +4193,10 @@ impl Editor { } return None; } - self.prepare_command(); + let read_only = self.diff.is_some() && !self.diff_editing(); + if !read_only { + self.prepare_command(); + } let action = entry.action; let name = entry.ex.expect("Ex dispatcher requires an alias"); // Switching must park directed Visual selections before ordinary Ex cleanup. @@ -3818,7 +4212,9 @@ impl Editor { } return None; } - self.finish_command_mode(); + if !read_only { + self.finish_command_mode(); + } match action { Action::LspDefinition | Action::LspHover | Action::LspDiagnostics if !args.is_empty() => @@ -3884,6 +4280,7 @@ impl Editor { /// 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() && effects .iter() .any(|effect| matches!(effect, Effect::Insert(_) | Effect::Replace(_))) @@ -3903,6 +4300,14 @@ impl Editor { } validate_text(text)?; } + if self.diff.is_some() && !self.diff_editing() { + for effect in effects { + if let Effect::Message(text) = effect { + self.set_message(text); + } + } + return Ok(()); + } // Validation precedes even history/message changes, so a rejected response is atomic. let previous_mode = self.active.mode; let inserting = self.active.mode == Mode::Insert; @@ -3958,6 +4363,7 @@ impl Editor { if self.active.mode != previous_mode { self.refresh_palette(); } + self.refresh_live_diff(); Ok(()) } @@ -3991,6 +4397,12 @@ impl Editor { if self.active.path.as_ref() != Some(&path) { self.active.semantic_tokens = None; } + if self.active.identity.as_ref() != Some(&identity) + && let Some(diff) = &mut self.diff + { + diff.live_buffer = None; + diff.live_revision = None; + } self.active.path = Some(path); self.active.identity = Some(identity); self.set_message("Written"); @@ -6419,6 +6831,11 @@ mod tests { editor.handle(input); } editor.handle(Input::Escape); + assert!(editor.diff().is_some()); + type_text(&mut editor, ":unknown\n"); + editor.handle(Input::Ctrl('p')); + editor.handle(Input::Escape); + editor.execute("diff.close"); assert_eq!(editor.mode(), Mode::Visual); assert_eq!(editor.carets(), carets); assert_eq!(editor.selections(), selections); @@ -6442,6 +6859,13 @@ mod tests { editor.execute("diff"); editor.handle(Input::Paste("ignored".into())); editor.handle(Input::Escape); + assert!(editor.diff().is_some()); + type_text(&mut editor, ":unfinished"); + editor.handle(Input::Escape); + editor + .apply(vec![Effect::Message("message without edits".into())]) + .unwrap(); + editor.execute("diff.close"); assert_eq!(editor.mode(), Mode::Insert); assert_eq!(editor.cursor(), caret); type_text(&mut editor, "finish"); @@ -6450,6 +6874,247 @@ mod tests { assert_eq!(editor.context().text, "alpha"); } + fn working_diff(editor: &mut Editor, root: &Path, paths: &[&str]) -> Box<[(PathBuf, String)]> { + use crate::diff::{ + Catalog, Category, ChangedFile, Comparison, FileStatus, RepoKind, Revision, + }; + editor.execute("diff"); + let (id, _) = editor.take_diff_request().unwrap(); + let revision = |target, label: &str| Revision { + target, + label: label.into(), + description: String::new(), + category: Category::Commits, + }; + editor.apply_diff_result( + id, + Ok(Outcome::Catalog(Catalog { + kind: RepoKind::Git, + entries: vec![ + revision(RevisionTarget::Commit("base".into()), "base"), + revision(RevisionTarget::Working, "Working"), + ], + notice: None, + })), + ); + editor.handle(Input::Down); + editor.handle(Input::Enter); + editor.handle(Input::Enter); + let (id, operation) = editor.take_diff_request().unwrap(); + let Operation::Compare { buffers, .. } = operation else { + panic!("comparison request") + }; + let files = paths + .iter() + .map(|path| { + let text = fs::read_to_string(root.join(path)).unwrap(); + let lines: Vec = text.lines().map(str::to_owned).collect(); + ChangedFile { + path: PathBuf::from(*path), + old_path: None, + status: FileStatus::Modified, + before: lines.clone(), + after: lines, + before_eof: text.ends_with('\n'), + after_eof: text.ends_with('\n'), + hunks: Vec::new(), + notice: None, + } + }) + .collect(); + assert!(editor.apply_diff_result( + id, + Ok(Outcome::Comparison(Comparison { + root: root.canonicalize().unwrap(), + before: "base".into(), + after: "Working".into(), + editable_paths: paths.iter().map(|path| PathBuf::from(*path)).collect(), + files, + })) + )); + buffers + } + + #[test] + fn working_diff_reuses_dirty_buffers_and_normal_edit_undo_save_paths() { + let directory = Directory::new(); + let a = directory.path("a.txt"); + let b = directory.path("b.txt"); + fs::write(&a, "alpha\n").unwrap(); + fs::write(&b, "beta").unwrap(); + let mut editor = Editor::open(Some(a.clone()), vec![]).unwrap(); + type_text(&mut editor, "iunsaved"); + editor.handle(Input::Escape); + editor.execute(&format!("edit {}", b.display())); + type_text(&mut editor, "iother"); + editor.handle(Input::Escape); + let overlays = working_diff(&mut editor, &directory.0, &["a.txt", "b.txt"]); + assert_eq!(overlays.len(), 2); + assert!(overlays.contains(&(a.canonicalize().unwrap(), "unsavedalpha\n".into()))); + assert!(overlays.contains(&(b.canonicalize().unwrap(), "otherbeta".into()))); + assert_eq!(editor.buffer_count(), 2); + assert_eq!(editor.buffer_id(), 1); + assert!(editor.diff_editable()); + assert!(!editor.diff_editing()); + editor.handle(Input::BackTab); + assert!(editor.diff_editing()); + type_text(&mut editor, "0i[]"); + editor.handle(Input::Char('\t')); + editor.handle(Input::Escape); + assert_eq!(editor.lines()[0], "[]\tunsavedalpha"); + assert!(editor.diff().is_some()); + editor.handle(Input::Char('u')); + assert_eq!(editor.lines()[0], "unsavedalpha"); + editor.handle(Input::Char('u')); + assert_eq!(editor.lines(), ["alpha", ""]); + let row = editor.cursor().row; + assert!(editor.diff().unwrap().after_display_row(row).is_some()); + type_text(&mut editor, "ccreplacement"); + editor.handle(Input::Escape); + editor.execute("w"); + assert_eq!(fs::read_to_string(&a).unwrap(), "replacement\n"); + assert!(!editor.dirty()); + assert_eq!(editor.buffer(2).unwrap().lines, ["otherbeta"]); + editor.execute("diff.close"); + assert!(editor.diff().is_none()); + editor.execute("buffer 2"); + assert!(editor.dirty()); + editor.handle(Input::Char('u')); + assert_eq!(editor.lines(), ["beta"]); + } + + #[test] + fn diff_readonly_panes_dispatch_prompts_palette_and_picker_remaps_without_edits() { + let directory = Directory::new(); + let path = directory.path("file.txt"); + fs::write(&path, "base").unwrap(); + let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); + working_diff(&mut editor, &directory.0, &["file.txt"]); + editor.handle(Input::Char('\t')); + assert_eq!(editor.diff().unwrap().focus, Focus::Before); + type_text(&mut editor, "idd"); + assert_eq!(editor.lines(), ["base"]); + editor.execute("w"); + assert_eq!(fs::read_to_string(&path).unwrap(), "base"); + assert!( + editor + .apply(vec![Effect::Insert("blocked".into())]) + .is_err() + ); + editor + .bind("normal", "ctrl+e", Some("core.right".into())) + .unwrap(); + editor.handle(Input::Ctrl('e')); + assert_eq!(editor.diff().unwrap().focus, Focus::Before); + editor + .bind("normal", "leader f", Some("core.file_picker".into())) + .unwrap(); + type_text(&mut editor, " f"); + assert_eq!( + editor.file_picker().unwrap().presentation, + FilePresentation::Floating + ); + editor.handle(Input::Escape); + editor.handle(Input::Escape); + assert!(editor.file_picker().is_none()); + assert!(editor.diff().is_some()); + editor.handle(Input::Ctrl('p')); + type_text(&mut editor, "diff.close"); + editor.handle(Input::Enter); + assert!(editor.diff().is_none()); + editor.execute("diff"); + type_text(&mut editor, ":diffclose\n"); + assert!(editor.diff().is_some()); + type_text(&mut editor, ":diff.close\n"); + assert!(editor.diff().is_none()); + } + + #[test] + fn diff_wheel_scroll_does_not_move_or_edit_insert_caret_and_plugin_edits_refresh() { + let directory = Directory::new(); + let path = directory.path("file.txt"); + fs::write(&path, "first\nsecond\n").unwrap(); + let mut editor = Editor::open(Some(path), vec![]).unwrap(); + working_diff(&mut editor, &directory.0, &["file.txt"]); + editor.handle(Input::BackTab); + editor.handle(Input::Char('i')); + let caret = editor.cursor(); + editor.scroll_diff(Focus::After, true); + assert_eq!(editor.cursor(), caret); + assert!(!editor.diff().unwrap().follow_caret); + assert_eq!(editor.lines(), ["first", "second", ""]); + editor.apply(vec![Effect::Insert("plugin".into())]).unwrap(); + assert_eq!( + editor.diff().unwrap().comparison.as_ref().unwrap().files[0].after, + editor.lines() + ); + assert_eq!( + editor.diff().unwrap().live_revision, + Some(editor.text_revision()) + ); + editor.handle(Input::Escape); + editor.handle(Input::Char('u')); + assert_eq!(editor.lines(), ["first", "second", ""]); + editor.execute("diff.close"); + } + + #[cfg(unix)] + #[test] + fn working_diff_rejects_symlinks_and_paths_escaping_repository_root() { + use std::os::unix::fs::symlink; + let directory = Directory::new(); + let outside = Directory::new(); + let main = directory.path("main.txt"); + let target = outside.path("target.txt"); + fs::write(&main, "main").unwrap(); + fs::write(&target, "outside").unwrap(); + symlink(&target, directory.path("link.txt")).unwrap(); + symlink(&outside.0, directory.path("escape")).unwrap(); + let mut editor = Editor::open(Some(main), vec![]).unwrap(); + for path in ["link.txt", "escape/target.txt"] { + working_diff(&mut editor, &directory.0, &[path]); + assert!(!editor.diff_editable()); + assert_eq!(editor.buffer_count(), 1); + 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"); + } + } + + #[test] + fn selecting_removed_or_historical_diff_file_never_opens_an_unrelated_buffer() { + let directory = Directory::new(); + let main = directory.path("main.txt"); + let other = directory.path("other.txt"); + fs::write(&main, "main").unwrap(); + fs::write(&other, "other").unwrap(); + let mut editor = Editor::open(Some(main), vec![]).unwrap(); + working_diff(&mut editor, &directory.0, &["main.txt", "other.txt"]); + let diff = editor.diff.as_mut().unwrap(); + let comparison = diff.comparison.as_mut().unwrap(); + comparison.editable_paths.remove(Path::new("other.txt")); + comparison.files[1].status = crate::diff::FileStatus::Removed; + comparison.files[1].after.clear(); + assert!(diff.select_file(Path::new("other.txt"))); + editor.handle(Input::BackTab); + assert!(!editor.diff_editable()); + 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()); + assert!(diff.select_file(Path::new("main.txt"))); + editor.handle(Input::Char('i')); + assert!(!editor.diff_editable()); + assert_eq!(editor.buffer_count(), 1); + editor.execute("diff.close"); + } + #[test] fn line_actions_counted_delete_restores_caret_and_pastes_whole_rows() { let mut editor = editor(); @@ -10040,7 +10705,7 @@ mod tests { } #[test] - fn built_in_alias_bindings_require_typed_normal_actions() { + fn built_in_alias_bindings_require_typed_actions() { let mut editor = editor(); for entry in CORE_ACTIONS.iter().filter(|entry| entry.ex.is_some()) { assert!( @@ -10049,7 +10714,6 @@ mod tests { .is_err() ); assert!(editor.bind("normal", "Z", Some(entry.name.into())).is_ok()); - assert!(editor.bind("insert", "Z", Some(entry.name.into())).is_err()); } } diff --git a/src/files.rs b/src/files.rs index 5b045e4..ee7180f 100644 --- a/src/files.rs +++ b/src/files.rs @@ -165,10 +165,10 @@ impl FilePicker { Ok(Self::from_nodes(root, nodes, presentation, view, warning)) } - /// Builds the changed-path catalog without consulting the working tree. - pub fn from_paths(root: PathBuf, paths: impl IntoIterator) -> Self { + /// Builds a changed-path catalog, retaining each path's comparison index. + pub fn from_paths(root: PathBuf, paths: impl IntoIterator) -> Self { let mut catalog = HashMap::new(); - for (index, path) in paths.into_iter().enumerate() { + for (index, path) in paths { for parent in path .ancestors() .skip(1) @@ -719,63 +719,69 @@ impl FilePicker { pub(crate) fn refresh(&mut self) { match Self::scan(self.root.clone(), self.presentation, self.view) { - Ok(mut replacement) => { - let by_path: HashMap<_, _> = replacement - .nodes - .iter() - .enumerate() - .map(|(index, node)| (node.path.clone(), index)) - .collect(); - let surviving = |node: usize| { - let mut path = self.nodes[node].path.as_path(); - loop { - if let Some(&node) = by_path.get(path) { - return Some(node); - } - path = path.parent()?; - } - }; - replacement.expanded = self - .expanded - .iter() - .filter_map(|&node| { - by_path - .get(&self.nodes[node].path) - .copied() - .filter(|&node| replacement.nodes[node].is_dir) - }) - .collect(); - replacement.filter_collapsed = self - .filter_collapsed - .iter() - .filter_map(|&node| { - by_path - .get(&self.nodes[node].path) - .copied() - .filter(|&node| replacement.nodes[node].is_dir) - }) - .collect(); - replacement.saved_selection = - self.saved_selection.map(|node| node.and_then(surviving)); - replacement.filtered_selection = - self.filtered_selection.map(|node| node.and_then(surviving)); - replacement.query = self.query.clone(); - replacement.focused = self.focused; - replacement.view = self.view; - replacement.searching = self.searching; - replacement.tree_searching = self.tree_searching; - let selected = self - .selected_node() - .or(self.filtered_selection[self.view.index()]) - .or(self.saved_selection[self.view.index()]) - .and_then(surviving); - replacement.project(selected, false); - replacement.remember_selection(false); - *self = replacement; - } + Ok(replacement) => self.replace_catalog(replacement), Err(error) => self.error = Some(format!("Refresh failed: {error}")), } } + + pub(crate) fn replace_paths(&mut self, paths: impl IntoIterator) { + self.replace_catalog(Self::from_paths(self.root.clone(), paths)); + } + + fn replace_catalog(&mut self, mut replacement: Self) { + replacement.presentation = self.presentation; + let by_path: HashMap<_, _> = replacement + .nodes + .iter() + .enumerate() + .map(|(index, node)| (node.path.clone(), index)) + .collect(); + let surviving = |node: usize| { + let mut path = self.nodes[node].path.as_path(); + loop { + if let Some(&node) = by_path.get(path) { + return Some(node); + } + path = path.parent()?; + } + }; + replacement.expanded = self + .expanded + .iter() + .filter_map(|&node| { + by_path + .get(&self.nodes[node].path) + .copied() + .filter(|&node| replacement.nodes[node].is_dir) + }) + .collect(); + replacement.filter_collapsed = self + .filter_collapsed + .iter() + .filter_map(|&node| { + by_path + .get(&self.nodes[node].path) + .copied() + .filter(|&node| replacement.nodes[node].is_dir) + }) + .collect(); + replacement.saved_selection = self.saved_selection.map(|node| node.and_then(surviving)); + replacement.filtered_selection = + self.filtered_selection.map(|node| node.and_then(surviving)); + replacement.query = self.query.clone(); + replacement.focused = self.focused; + replacement.view = self.view; + replacement.searching = self.searching; + replacement.tree_searching = self.tree_searching; + let selected = self + .selected_node() + .or(self.filtered_selection[self.view.index()]) + .or(self.saved_selection[self.view.index()]) + .and_then(surviving); + replacement.project(selected, false); + replacement.remember_selection(false); + *self = replacement; + } } #[cfg(test)] @@ -786,7 +792,10 @@ mod tests { fn virtual_catalog_compacts_and_retains_file_directory_collisions() { let mut picker = FilePicker::from_paths( PathBuf::new(), - ["x", "x/y", "src/deep/deleted.rs", "src/deep/deleted.rs"].map(PathBuf::from), + ["x", "x/y", "src/deep/deleted.rs", "src/deep/deleted.rs"] + .map(PathBuf::from) + .into_iter() + .enumerate(), ); assert_eq!( picker diff --git a/src/main.rs b/src/main.rs index 279b078..edfc6ac 100644 --- a/src/main.rs +++ b/src/main.rs @@ -40,10 +40,12 @@ Commands: :w [PATH], :q, :q!, :wq [PATH], :plugins, :PLUGIN [ARGS] Buffers: :edit PATH, :buffers, :buffer N, :bnext, :bprev, :bdelete[!] [N] :q refuses any dirty buffer; :wq saves active and quits only if all are clean Markdown links: :follow_link (primary caret; local files only) -Diff: :diff (Normal); choose Before then After; Working checkout means disk, not unsaved buffers - Tab/Shift+Tab categories/focus; type to search IDs/refs; arrows navigate/scroll; Esc back/close +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 + Type to search IDs/refs; Normal [/] expands excerpts; Insert brackets remain literal text + :diff.close exits explicitly; Esc cancels modes/prompts/filtering without closing the diff Remote refs/history load into temporary comparison repos without changing active refs/checkout - Changed-file tree: Ctrl+e hides/shows; A added, M modified, D removed, R renamed (old path shown) + Changed files: normal picker/explorer bindings, Ctrl+w close; A/M/D/R status and rename paths Ruby config: $XDG_CONFIG_HOME/vamp/config.rb or ~/.config/vamp/config.rb Set VAMP_RUBY to choose the Ruby executable. diff --git a/src/repository.rs b/src/repository.rs index e111633..edf6dd5 100644 --- a/src/repository.rs +++ b/src/repository.rs @@ -1,7 +1,7 @@ //! Read-only active-repository discovery and isolated revision comparison. use crate::diff::{ - Catalog, Category, ChangedFile, Comparison, FileStatus, Hunk, RepoKind, Revision, - RevisionTarget, + Catalog, Category, ChangedFile, Comparison, FileStatus, RepoKind, Revision, RevisionTarget, + line_hunks, }; use std::{ collections::{BTreeMap, BTreeSet}, @@ -934,7 +934,7 @@ impl Repository { } Ok(snapshot) } - fn working_snapshot(&self) -> Result { + fn working_snapshot(&self, buffers: &[(PathBuf, String)]) -> Result { let mut tracked = BTreeSet::new(); let mut submodules = BTreeSet::new(); if self.kind == RepoKind::Git { @@ -1017,6 +1017,32 @@ impl Repository { } } } + let overlays: BTreeMap<_, _> = buffers + .iter() + .filter_map(|(path, text)| { + let relative = path.strip_prefix(&self.root).ok()?; + if safe_path(relative).is_err() + || !path.is_absolute() + || text.as_bytes().contains(&0) + { + return None; + } + // Reject symlinks in every component, not just the final file. + let mut full = self.root.clone(); + for component in relative.components() { + full.push(component); + let metadata = fs::symlink_metadata(&full).ok()?; + if metadata.file_type().is_symlink() { + return None; + } + } + fs::symlink_metadata(&full) + .ok()? + .is_file() + .then_some((relative.to_path_buf(), text)) + }) + .collect(); + paths.extend(overlays.keys().cloned()); let mut total = 0; let mut snapshot = BTreeMap::new(); for path in paths { @@ -1110,6 +1136,20 @@ impl Repository { path.display() )); } + let bytes = if !bytes.contains(&0) + && std::str::from_utf8(&bytes).is_ok() + && let Some(text) = overlays.get(&path) + { + if text.len() > FILE_LIMIT { + return Err(format!( + "{} exceeds the 16 MiB comparison file limit", + path.display() + )); + } + text.as_bytes().to_vec() + } else { + bytes + }; #[cfg(unix)] let executable = { use std::os::unix::fs::PermissionsExt; @@ -1146,16 +1186,24 @@ impl Repository { } fn snapshot(&mut self, target: &RevisionTarget) -> Result { if *target == RevisionTarget::Working { - self.working_snapshot() + self.working_snapshot(&[]) } else { let (store, id) = self.resolve(target)?; self.commit_snapshot(&store, &id) } } - pub fn compare(&mut self, before: &Revision, after: &Revision) -> Result { + pub fn compare( + &mut self, + before: &Revision, + after: &Revision, + buffers: &[(PathBuf, String)], + ) -> Result { let left = self.snapshot(&before.target)?; - let right = if before.target == after.target { + let working_after = after.target == RevisionTarget::Working; + let right = if before.target == after.target && (!working_after || buffers.is_empty()) { None + } else if working_after { + Some(self.working_snapshot(buffers)?) } else { Some(self.snapshot(&after.target)?) }; @@ -1199,15 +1247,36 @@ impl Repository { removed.insert(j); } } - let files = files + let files: Vec<_> = files .into_iter() .enumerate() .filter_map(|(i, f)| (!removed.contains(&i)).then_some(f)) .collect(); + let editable_paths = if working_after { + files + .iter() + .filter(|file| { + right.get(&file.path).is_some_and(|data| { + matches!(data.mode.as_str(), "100644" | "100755") + && !data.bytes.contains(&0) + && std::str::from_utf8(&data.bytes).is_ok() + && file + .notice + .as_deref() + .is_none_or(|notice| !notice.contains("Binary or non-UTF-8")) + }) + }) + .map(|file| file.path.clone()) + .collect() + } else { + BTreeSet::new() + }; Ok(Comparison { before: before.label.clone(), after: after.label.clone(), files, + root: self.root.clone(), + editable_paths, }) } fn changed_file( @@ -1253,38 +1322,15 @@ impl Repository { eof_label(after) )); } - let before_path = self.temporary.0.join("before"); - let after_path = self.temporary.0.join("after"); - fs::write(&before_path, a).map_err(|e| e.to_string())?; - fs::write(&after_path, b).map_err(|e| e.to_string())?; - let mut cmd = Command::new("git"); - cmd.args([ - "--no-pager", - "diff", - "--no-index", - "--no-color", - "--no-ext-diff", - "--no-textconv", - "--text", - "--unified=0", - "--", - ]) - .arg(&before_path) - .arg(&after_path) - .current_dir(&self.temporary.0); - let output = run(cmd, vec![], &self.cancelled)?; - if !matches!(output.code, Some(0 | 1)) { - return Err(format!( - "Cannot calculate diff hunks: {}", - String::from_utf8_lossy(&output.stderr) - )); - } - let patch = text(output.stdout)?; - let mut hunks = vec![]; - for line in patch.lines().filter(|l| l.starts_with("@@ ")) { - hunks.push(parse_hunk(line)?); - } - (lines(a), lines(b), hunks) + let before_lines = lines(a); + let after_lines = lines(b); + let hunks = line_hunks( + &before_lines, + a.ends_with(b"\n"), + &after_lines, + b.ends_with(b"\n"), + ); + (before_lines, after_lines, hunks) }; notices.dedup(); Ok(ChangedFile { @@ -1293,6 +1339,8 @@ impl Repository { status, before: before_lines, after: after_lines, + before_eof: a.ends_with(b"\n"), + after_eof: b.ends_with(b"\n"), hunks, notice: if notices.is_empty() { None @@ -1318,35 +1366,6 @@ fn eof_label(file: Option<&FileData>) -> &'static str { Some(_) => "missing", } } -fn parse_hunk(line: &str) -> Result { - fn range(value: &str) -> Result<(usize, usize), String> { - let (start, len) = value[1..].split_once(',').unwrap_or((&value[1..], "1")); - let start: usize = start.parse().map_err(|_| "Invalid diff hunk start")?; - let len: usize = len.parse().map_err(|_| "Invalid diff hunk length")?; - Ok(( - if len == 0 { - start - } else { - start - .checked_sub(1) - .ok_or("Invalid nonempty diff hunk start")? - }, - len, - )) - } - let mut fields = line.split_whitespace(); - if fields.next() != Some("@@") { - return Err("Invalid diff hunk header".into()); - } - let (before_start, before_len) = range(fields.next().ok_or("Missing Before hunk range")?)?; - let (after_start, after_len) = range(fields.next().ok_or("Missing After hunk range")?)?; - Ok(Hunk { - before_start, - before_len, - after_start, - after_len, - }) -} #[cfg(all(test, unix))] mod tests { diff --git a/src/ruby.rs b/src/ruby.rs index 196c012..9304036 100644 --- a/src/ruby.rs +++ b/src/ruby.rs @@ -933,8 +933,19 @@ end config.bind "V", config.core.buffer_previous config.bind "D", config.core.buffer_delete config.bind "Z", config.core.buffer_force_delete + config.bind "R", config.core.diff + config.bind "T", config.core.diff.close + raise "mutable diff actions" unless config.core.frozen? && config.core.diff.frozen? && + config.core.diff.close.frozen? && config.core.diff.name.frozen? && + config.core.diff.close.name.frozen? end"#, ); + let mut review = EditorSession::open(None, vec!["text".into()], Some(&config)).unwrap(); + review.handle(Input::Char('R')); + review.handle(Input::Escape); + assert!(review.editor().diff().is_some()); + review.handle(Input::Char('T')); + assert!(review.editor().diff().is_none()); for (key, dirty, quit, saved) in [ ('Q', false, true, false), ('Q', true, false, false), @@ -1047,6 +1058,9 @@ end "config.core.lsp", "config.core.lsp.hover.tap { |action| action.name = 'core.delete' }", "config.core.lsp.tap { |namespace| namespace.define_singleton_method(:bogus) { namespace.hover } }.bogus", + "config.core.diff.tap { |action| action.name = 'core.delete' }", + "config.core.diff.tap { |action| action.define_singleton_method(:bogus) { action.close } }.bogus", + "config.core.diff.close.tap { |action| action.name = 'core.delete' }", ] { let invalid = fixture.file( "invalid_action.rb", diff --git a/src/ruby_values.rb b/src/ruby_values.rb index 1bc90b5..9689fed 100644 --- a/src/ruby_values.rb +++ b/src/ruby_values.rb @@ -59,13 +59,13 @@ module Vamp Core = Module.new # @param entries [Array String, nil}>] action name and optional Ex alias descriptors - # @return [Array] frozen action namespaces + # @return [Array] frozen action namespaces and leaves def self.install_actions(entries) raise TypeError, "core action catalog must be an Array" unless entries.is_a?(Array) namespaces = [Core] aliases = {} - entries.each do |entry| + entries.sort_by { |entry| entry.fetch("name").count(".") }.each do |entry| raise TypeError, "core action descriptor must be a Hash" unless entry.is_a?(Hash) install_action(entry.fetch("name"), namespaces) @@ -139,7 +139,7 @@ module Vamp end # @param name [String] - # @param namespaces [Array] + # @param namespaces [Array] # @return [Symbol] the installed action method name def self.install_action(name, namespaces) name = action_name(name) @@ -147,7 +147,8 @@ module Vamp namespace = parts[0...-1].reduce(Core) { |parent, part| action_namespace(parent, part, namespaces, name) } leaf = parts.last available_action!(namespace, leaf, name) - action = Action.send(:new, name.freeze).freeze + action = Action.send(:new, name.freeze) + namespaces << action namespace.define_singleton_method(leaf) { action } end @@ -160,23 +161,23 @@ module Vamp name end - # @param parent [Module] + # @param parent [Module, Vamp::Action] # @param part [String] - # @param namespaces [Array] + # @param namespaces [Array] # @param name [String] - # @return [Module] + # @return [Module, Vamp::Action] def self.action_namespace(parent, part, namespaces, name) return new_action_namespace(parent, part, namespaces) unless parent.singleton_methods(false).include?(part.to_sym) child = parent.public_send(part) - raise ArgumentError, "conflicting core action namespace: #{name}" unless child.is_a?(Module) + raise ArgumentError, "conflicting core action namespace: #{name}" unless child.is_a?(Module) || child.is_a?(Action) child end - # @param parent [Module] + # @param parent [Module, Vamp::Action] # @param part [String] - # @param namespaces [Array] + # @param namespaces [Array] # @return [Module] def self.new_action_namespace(parent, part, namespaces) child = Module.new @@ -185,7 +186,7 @@ module Vamp child end - # @param namespace [Module] + # @param namespace [Module, Vamp::Action] # @param leaf [String] # @param name [String] # @return [nil] diff --git a/src/session.rs b/src/session.rs index 6bc7186..9a4ff53 100644 --- a/src/session.rs +++ b/src/session.rs @@ -11,7 +11,7 @@ use std::{ }; use crate::{ - diff::{Operation as DiffOperation, Outcome as DiffOutcome}, + diff::{Focus, Operation as DiffOperation, Outcome as DiffOutcome}, editor::{Cursor, Editor, Effect, Input, Invocation, Mode}, lsp::Lsp, plugin::{ @@ -71,8 +71,12 @@ impl DiffWorker { DiffOperation::FetchRemote(remote) => { repository.fetch_remote(&remote).map(DiffOutcome::Catalog) } - DiffOperation::Compare { before, after } => repository - .compare(&before, &after) + DiffOperation::Compare { + before, + after, + buffers, + } => repository + .compare(&before, &after, &buffers) .map(DiffOutcome::Comparison), } })(); @@ -254,6 +258,10 @@ impl EditorSession { &self.editor } + pub(crate) fn scroll_diff(&mut self, focus: Focus, down: bool) { + self.editor.scroll_diff(focus, down); + } + pub fn handle(&mut self, input: Input) { self.cancel_pending_popup_request(); let invocation = self.editor.handle(input); @@ -386,6 +394,7 @@ impl EditorSession { return; } let context = self.editor.context(); + let read_only = self.editor.diff().is_some() && !self.editor.diff_editing(); 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(); @@ -402,7 +411,18 @@ impl EditorSession { } Some(plugin) => plugin .invoke(&invocation.command, &invocation.args, &context) - .and_then(|effects| self.editor.apply(effects)), + .and_then(|effects| { + if read_only + && effects + .iter() + .any(|effect| matches!(effect, Effect::Insert(_) | Effect::Replace(_))) + { + self.editor.set_message("Diff pane is read-only"); + Ok(()) + } else { + self.editor.apply(effects) + } + }), None => Err("plugin host is unavailable".into()), }; if let Err(error) = result { @@ -555,7 +575,7 @@ impl EditorSession { request_id: Option, source: InvocationSource, ) -> bool { - if self.editor.diff().is_some() { + if self.editor.diff().is_some() && !self.editor.diff_editing() { return false; } self.editor.buffer_id() == source.buffer_id @@ -791,6 +811,7 @@ impl EditorSession { } } } + self.editor.refresh_live_diff(); Ok(changed) } @@ -807,10 +828,7 @@ impl EditorSession { let Some(source) = source else { return Err("plugin response has an unknown invocation ID".into()); }; - if !self.source_is_current(source) - || self.editor.buffer_id() != source.buffer_id - || self.editor.diff().is_some() - { + if !self.source_is_current(source) || self.editor.buffer_id() != source.buffer_id { return Ok(false); } if effects @@ -826,6 +844,9 @@ impl EditorSession { } return Ok(changed); } + if self.editor.diff().is_some() && !self.editor.diff_editing() { + return Ok(false); + } let changed = !effects.is_empty(); self.editor.apply(effects)?; Ok(changed) @@ -3314,11 +3335,131 @@ end }); assert_eq!(session.editor().context().text, "original"); assert_eq!(fs::read_to_string(&source).unwrap(), "original"); - session.handle(Input::Escape); + session.execute("diff.close"); session.execute("ping"); drain_until(&mut session, |session| { session.editor().message() == "still alive" }); assert_eq!(session.editor().context().text, "original"); } + + #[test] + fn async_responses_edit_only_current_bound_working_after_and_refresh_projection() { + use crate::diff::{ + Catalog, Category, ChangedFile, Comparison, FileStatus, RepoKind, Revision, + RevisionTarget, + }; + let fixture = Fixture::new(); + fixture.file("buffer.txt", "base"); + let mut session = fixture.open(""); + session.editor.execute("diff"); + let (id, _) = session.editor.take_diff_request().unwrap(); + let revision = |target, label: &str| Revision { + target, + label: label.into(), + description: String::new(), + category: Category::Commits, + }; + session.editor.apply_diff_result( + id, + Ok(DiffOutcome::Catalog(Catalog { + kind: RepoKind::Git, + notice: None, + entries: vec![ + revision(RevisionTarget::Commit("base".into()), "base"), + revision(RevisionTarget::Working, "Working"), + ], + })), + ); + for input in [Input::Down, Input::Enter, Input::Enter] { + session.editor.handle(input); + } + let (id, _) = session.editor.take_diff_request().unwrap(); + session.editor.apply_diff_result( + id, + Ok(DiffOutcome::Comparison(Comparison { + root: fixture.0.canonicalize().unwrap(), + before: "base".into(), + after: "Working".into(), + editable_paths: [PathBuf::from("buffer.txt")].into_iter().collect(), + files: vec![ChangedFile { + path: PathBuf::from("buffer.txt"), + old_path: None, + status: FileStatus::Modified, + before: vec!["base".into()], + after: vec!["base".into()], + before_eof: false, + after_eof: false, + hunks: Vec::new(), + notice: None, + }], + })), + ); + session.editor.handle(Input::BackTab); + let source = InvocationSource { + buffer_id: session.editor.buffer_id(), + revision: session.editor.text_revision(), + }; + session + .requests + .entry("fixture".into()) + .or_default() + .pending + .insert(1, source); + assert!( + session + .apply_response("fixture", 1, vec![Effect::Insert("accepted".into())]) + .unwrap() + ); + assert_eq!(session.editor.lines(), ["acceptedbase"]); + let diff = session.editor.diff().unwrap(); + assert_eq!(diff.live_revision, Some(session.editor.text_revision())); + assert_eq!( + diff.comparison.as_ref().unwrap().files[0].after, + session.editor.lines() + ); + + session + .requests + .get_mut("fixture") + .unwrap() + .pending + .insert(2, source); + assert!( + !session + .apply_response("fixture", 2, vec![Effect::Insert("stale".into())]) + .unwrap() + ); + let current = 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); + assert!( + !session + .apply_response("fixture", 3, vec![Effect::Insert("readonly".into())]) + .unwrap() + ); + assert_eq!(session.editor.lines(), ["acceptedbase"]); + session + .requests + .get_mut("fixture") + .unwrap() + .pending + .insert(4, current); + assert!( + session + .apply_response("fixture", 4, vec![Effect::Message("allowed".into())]) + .unwrap() + ); + assert_eq!(session.editor.message(), "allowed"); + session.editor.execute("diff.close"); + assert!(session.editor.dirty()); + } } diff --git a/src/tui.rs b/src/tui.rs index 83626bf..30c93c7 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -424,6 +424,7 @@ fn draw( editor.selection_navigation_hint(), editor.file_navigation_hint(), panels, + Some(editor), ); return; } @@ -537,207 +538,15 @@ fn draw( .map(|line| Line::from(visible_line(line, *left, width))) .collect(); frame.render_widget(Paragraph::new(lines), viewport); - let buffer = frame.buffer_mut(); - let semantic = editor.semantic_tokens(); - let mut semantic_index = semantic.partition_point(|token| token.start.row < *top); for (row, line) in editor.lines().iter().enumerate().skip(*top).take(height) { - let spans = syntax.line(row); - while semantic_index < semantic.len() && semantic[semantic_index].start.row < row { - semantic_index += 1; - } - if spans.is_empty() - && semantic - .get(semantic_index) - .is_none_or(|token| token.start.row != row) - { - continue; - } - let mut span_index = 0; - let mut token_style = None; - let mut column = 0; - let right = left.saturating_add(width); - for (index, (byte, grapheme)) in line.grapheme_indices(true).enumerate() { - if column >= right { - break; - } - let cells = if grapheme == "\t" { - TAB_WIDTH - column % TAB_WIDTH - } else { - UnicodeWidthStr::width(grapheme) - }; - let end = column + cells; - while span_index < spans.len() && spans[span_index].end_byte <= byte { - span_index += 1; - } - while semantic_index < semantic.len() - && semantic[semantic_index].start.row == row - && semantic[semantic_index].end.column <= index - { - semantic_index += 1; - token_style = None; - } - let style = semantic - .get(semantic_index) - .filter(|token| token.start.row == row && token.start.column <= index) - .map(|token| *token_style.get_or_insert_with(|| semantic_style(token))) - .or_else(|| { - spans - .get(span_index) - .filter(|span| span.start_byte <= byte) - .map(|span| syntax_style(span.kind)) - }); - if let Some(style) = style - && end > *left - && (grapheme == "\t" || (column >= *left && end <= right)) - { - let y = viewport.y + (row - *top) as u16; - for cell in column.max(*left)..end.min(right) { - buffer[(viewport.x + (cell - *left) as u16, y)].set_style(style); - } - } - column = end; - } - } - for severity in [ - DiagnosticSeverity::Hint, - DiagnosticSeverity::Information, - DiagnosticSeverity::Warning, - DiagnosticSeverity::Error, - ] { - let style = Style::default() - .underline_color(diagnostic_severity(severity).1) - .add_modifier(Modifier::UNDERLINED); - for diagnostic in editor - .diagnostics() - .filter(|item| item.severity == severity) - { - let first = diagnostic.start.row.max(*top); - let last = diagnostic.end.row.min(top.saturating_add(height - 1)); - for row in first..=last { - if !diagnostic_on_row(diagnostic, row) { - continue; - } - let start = if row == diagnostic.start.row { - diagnostic.start.column - } else { - 0 - }; - let end = if row == diagnostic.end.row { - diagnostic - .end - .column - .saturating_add(usize::from(diagnostic.start == diagnostic.end)) - } else { - usize::MAX - }; - let line = &editor.lines()[row]; - let y = viewport.y + (row - *top) as u16; - let right = left.saturating_add(width); - let mut column = 0; - let mut grapheme_count = 0; - for (index, grapheme) in line.graphemes(true).enumerate() { - let cells = if grapheme == "\t" { - TAB_WIDTH - column % TAB_WIDTH - } else { - UnicodeWidthStr::width(grapheme) - }; - let next = column + cells; - if index >= start - && index < end - && (grapheme == "\t" || (column >= *left && next <= right)) - { - for cell in column.max(*left)..next.min(right) { - frame.buffer_mut()[(viewport.x + (cell - *left) as u16, y)] - .set_style(style); - } - } - column = next; - grapheme_count = index + 1; - if column >= right { - break; - } - } - if start <= grapheme_count - && end > grapheme_count - && column >= *left - && column < right - { - frame.buffer_mut()[(viewport.x + (column - *left) as u16, y)] - .set_style(style); - } - } - } - } - if let Some(found) = editor.search_match() - && found.start.row >= *top - && found.start.row < top.saturating_add(height) - { - let line = &editor.lines()[found.start.row]; - let start = display_column(line, found.start.column).max(*left); - let end = display_column(line, found.end_column).min(left.saturating_add(width)); - let y = viewport.y + (found.start.row - *top) as u16; - let style = Style::default().fg(Color::Black).bg(Color::Yellow); - let buffer = frame.buffer_mut(); - for column in start..end { - buffer[(viewport.x + (column - *left) as u16, y)].set_style(style); - } - } - let selection_style = Style::default().fg(Color::White).bg(Color::Blue); - for rows in editor.linewise_rows() { - let first = (*rows.start()).max(*top); - let last = (*rows.end()).min(top.saturating_add(height - 1)); - for row in first..=last { - let end = display_column(&editor.lines()[row], usize::MAX) - .saturating_add(1) - .min(left.saturating_add(width)); - let y = viewport.y + (row - *top) as u16; - for column in *left..end { - frame.buffer_mut()[(viewport.x + (column - *left) as u16, y)] - .set_style(selection_style); - } - } - } - for selection in selections - .iter() - .filter(|_| !editor.visual_linewise()) - .chain(editor.snippet_selections()) - { - let first = selection.start.row.max(*top); - let last = selection.end.row.min(top.saturating_add(height - 1)); - let style = Style::default().fg(Color::White).bg(Color::Blue); - for row in first..=last { - let line = &editor.lines()[row]; - let start = if row == selection.start.row { - display_column(line, selection.start.column) - } else { - 0 - }; - let end = if row == selection.end.row { - display_column(line, selection.end.column) - } else { - display_column(line, usize::MAX) + 1 - }; - let start = start.max(*left); - let end = end.min(left.saturating_add(width)); - let y = viewport.y + (row - *top) as u16; - let buffer = frame.buffer_mut(); - for column in start..end { - buffer[(viewport.x + (column - *left) as u16, y)].set_style(style); - } - } - } - for caret in carets.iter().filter(|caret| **caret != cursor) { - if caret.row < *top || caret.row >= top.saturating_add(height) { - continue; - } - let column = display_column(&editor.lines()[caret.row], caret.column); - if column >= *left && column < left.saturating_add(width) { - frame.buffer_mut()[( - viewport.x + (column - *left) as u16, - viewport.y + (caret.row - *top) as u16, - )] - .set_style(Style::default().fg(Color::Black).bg(Color::Magenta)); - } + let area = Rect::new( + viewport.x, + viewport.y + (row - *top) as u16, + viewport.width, + 1, + ); + paint_syntax_line(frame, line, syntax.line(row), area, *left); + paint_editor_line(frame, editor, row, area, *left, &carets, &selections); } } let path = editor @@ -918,6 +727,268 @@ fn diff_panel(frame: &mut Frame, area: Rect, title: &str, focused: bool) -> Rect inner } +fn paint_cells(frame: &mut Frame, area: Rect, left: usize, start: usize, end: usize, style: Style) { + if area.height == 0 { + return; + } + for column in start.max(left)..end.min(left.saturating_add(usize::from(area.width))) { + frame.buffer_mut()[(area.x + (column - left) as u16, area.y)].set_style(style); + } +} + +fn paint_grapheme_range( + frame: &mut Frame, + area: Rect, + left: usize, + line: &str, + range: std::ops::Range, + style: Style, +) { + let right = left.saturating_add(usize::from(area.width)); + let mut column = 0; + let mut count = 0; + for (index, grapheme) in line.graphemes(true).enumerate() { + let cells = if grapheme == "\t" { + TAB_WIDTH - column % TAB_WIDTH + } else { + UnicodeWidthStr::width(grapheme) + }; + let next = column + cells; + if range.contains(&index) && (grapheme == "\t" || (column >= left && next <= right)) { + paint_cells(frame, area, left, column, next, style); + } + column = next; + count = index + 1; + if column >= right { + break; + } + } + if range.start <= count && range.end > count { + paint_cells(frame, area, left, column, column.saturating_add(1), style); + } +} + +fn paint_editor_line( + frame: &mut Frame, + editor: &Editor, + row: usize, + area: Rect, + left: usize, + carets: &[Cursor], + selections: &[Selection], +) { + let Some(line) = editor.lines().get(row) else { + return; + }; + let semantic = editor.semantic_tokens(); + let mut token = semantic.partition_point(|token| token.start.row < row); + let mut token_style = None; + let mut column = 0; + let right = left.saturating_add(usize::from(area.width)); + for (index, grapheme) in line.graphemes(true).enumerate() { + if column >= right { + break; + } + let cells = if grapheme == "\t" { + TAB_WIDTH - column % TAB_WIDTH + } else { + UnicodeWidthStr::width(grapheme) + }; + let end = column + cells; + while token < semantic.len() + && semantic[token].start.row == row + && semantic[token].end.column <= index + { + token += 1; + token_style = None; + } + if let Some(item) = semantic + .get(token) + .filter(|token| token.start.row == row && token.start.column <= index) + && (grapheme == "\t" || (column >= left && end <= right)) + { + let style = *token_style.get_or_insert_with(|| semantic_style(item)); + paint_cells(frame, area, left, column, end, style); + } + column = end; + } + for severity in [ + DiagnosticSeverity::Hint, + DiagnosticSeverity::Information, + DiagnosticSeverity::Warning, + DiagnosticSeverity::Error, + ] { + for diagnostic in editor + .diagnostics() + .filter(|item| item.severity == severity && diagnostic_on_row(item, row)) + { + let start = if row == diagnostic.start.row { + diagnostic.start.column + } else { + 0 + }; + let end = if row == diagnostic.end.row { + diagnostic + .end + .column + .saturating_add(usize::from(diagnostic.start == diagnostic.end)) + } else { + usize::MAX + }; + let style = Style::default() + .underline_color(diagnostic_severity(severity).1) + .add_modifier(Modifier::UNDERLINED); + paint_grapheme_range(frame, area, left, line, start..end, style); + } + } + if let Some(found) = editor.search_match().filter(|found| found.start.row == row) { + paint_cells( + frame, + area, + left, + display_column(line, found.start.column), + display_column(line, found.end_column), + Style::default().fg(Color::Black).bg(Color::Yellow), + ); + } + let selection_style = Style::default().fg(Color::White).bg(Color::Blue); + if editor.linewise_rows().any(|rows| rows.contains(&row)) { + paint_cells( + frame, + area, + left, + left, + display_column(line, usize::MAX).saturating_add(1), + selection_style, + ); + } + for selection in selections + .iter() + .filter(|_| !editor.visual_linewise()) + .chain(editor.snippet_selections()) + { + if selection.start.row <= row && row <= selection.end.row { + let start = if row == selection.start.row { + display_column(line, selection.start.column) + } else { + 0 + }; + let end = if row == selection.end.row { + display_column(line, selection.end.column) + } else { + display_column(line, usize::MAX).saturating_add(1) + }; + paint_cells(frame, area, left, start, end, selection_style); + } + } + for caret in carets + .iter() + .filter(|caret| caret.row == row && **caret != editor.cursor()) + { + let column = display_column(line, caret.column); + paint_cells( + frame, + area, + left, + column, + column.saturating_add(1), + Style::default().fg(Color::Black).bg(Color::Magenta), + ); + } +} + +fn paint_diff_editor( + frame: &mut Frame, + editor: &Editor, + diff: &DiffState, + area: Rect, + viewport: (usize, usize), +) -> Option { + let (top, left) = viewport; + let carets = editor.carets(); + let selections = editor.selections(); + let mut cursor = None; + for (display, row) in diff + .display_rows + .iter() + .enumerate() + .skip(top) + .take(usize::from(area.height)) + { + let DisplayRow::Line(aligned) = *row else { + continue; + }; + let Some(source) = diff.rows[aligned].after 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() { + 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)); + } + } + } + if let Some(position) = cursor + && !matches!(editor.mode(), Mode::Command | Mode::Search) + && editor.palette().is_none() + { + frame.set_cursor_position(position); + } + cursor +} + +fn draw_diff_popups( + frame: &mut Frame, + editor: &Editor, + area: Rect, + caret: Option<(Rect, Position)>, + panels: &mut WheelPanels, +) { + if let Some((text_area, caret)) = caret + && let Some(completion) = editor.completion() + { + panels.completion = draw_completion(frame, completion, text_area, caret); + } + if area.width > 0 && area.height > 0 { + let footer = Rect::new(area.x, area.bottom() - 1, area.width, 1); + if matches!(editor.mode(), Mode::Command | Mode::Search) { + let prefix = if editor.mode() == Mode::Search { + '/' + } else { + ':' + }; + let command = format!("{prefix}{}", editor.command_line()); + let column = display_column(&command, usize::MAX); + let offset = column.saturating_sub(usize::from(area.width).saturating_sub(1)); + frame.render_widget(Clear, footer); + frame.render_widget( + Paragraph::new(visible_line(&command, offset, usize::from(area.width))), + footer, + ); + frame.set_cursor_position((footer.x + (column - offset) as u16, footer.y)); + } else if !editor.message().is_empty() { + frame.render_widget(Clear, footer); + frame.render_widget(Paragraph::new(display_text(editor.message())), footer); + } + } + if let Some(palette) = editor.palette() { + draw_palette( + frame, + palette, + editor.mode(), + editor.selection_navigation_hint(), + ); + } + if let Some(hover) = editor.hover() { + draw_hover(frame, hover, caret.map_or(area, |(area, _)| area), panels); + } + if let Some(diagnostic) = editor.startup_diagnostic() { + draw_startup_diagnostic(frame, diagnostic, area); + } +} + fn draw_diff( frame: &mut Frame, diff: &DiffState, @@ -925,6 +996,7 @@ fn draw_diff( navigation_hint: &str, file_navigation_hint: &str, panels: &mut WheelPanels, + editor: Option<&Editor>, ) { let comparison = diff.comparison.as_ref(); let selected = comparison.and_then(|comparison| comparison.files.get(diff.file)); @@ -935,7 +1007,7 @@ fn draw_diff( panels .diff_syntax .get_or_insert_with(DiffSyntaxCache::default) - .update(id, diff.file, file); + .update(id, diff.file, file, diff.live_revision); } else { panels.diff_syntax = None; } @@ -1112,7 +1184,7 @@ fn draw_diff( } frame.render_widget( Paragraph::new(format!( - "{navigation_hint} · Tab categories · type search · Enter select · Esc back/cancel" + "{navigation_hint} · Tab categories · type search · Enter select · Esc back/clear · :diff.close" )) .style(Style::default().fg(Color::DarkGray)), help, @@ -1124,6 +1196,9 @@ fn draw_diff( (Rect::default(), Focus::After), ], }); + if let Some(editor) = editor { + draw_diff_popups(frame, editor, area, None, panels); + } return; } @@ -1150,10 +1225,20 @@ fn draw_diff( }); frame.render_widget( Paragraph::new(format!( - "Diff · {} → {} · {:?} focus · read-only", + "Diff · {} → {} · {:?} focus · {}", display_text(before), display_text(after), - diff.focus + 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, + ), + ) )) .style(Style::default().fg(Color::Black).bg(Color::Cyan)), header, @@ -1170,13 +1255,39 @@ fn draw_diff( notice_area, ); } - let (panes, navigator) = if diff.files_visible && diff.files.is_some() { + let (panes, navigator) = if diff.files_visible + && diff + .files + .as_ref() + .is_some_and(|files| files.presentation == FilePresentation::Docked) + { dock_layout(workspace, DockSide::Right, diff.focus == Focus::Files) } else { (workspace, None) }; let [before_area, after_area] = Layout::horizontal([Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)]).areas(panes); + let mut document_caret = None; + let mut top = diff.top; + let mut after_left = diff.left_after; + if let Some(editor) = editor.filter(|editor| editor.diff_editing() && diff.follow_caret) { + let height = usize::from(after_area.height.saturating_sub(2)); + if let Some(row) = diff.after_display_row(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 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 let Some(picker) = &diff.files && let Some(navigator) = navigator { @@ -1192,8 +1303,28 @@ fn draw_diff( if let Some(file) = selected && let Some(syntax) = &panels.diff_syntax { - draw_diff_pane(frame, diff, file, before_area, true, &syntax.before); - draw_diff_pane(frame, diff, file, after_area, false, &syntax.after); + draw_diff_pane( + frame, + diff, + file, + before_area, + true, + &syntax.before, + (top, diff.left_before), + ); + let text_area = draw_diff_pane( + frame, + diff, + file, + after_area, + false, + &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)); + } } else { frame.render_widget( Paragraph::new(if diff.loading { @@ -1206,7 +1337,7 @@ fn draw_diff( } frame.render_widget( Paragraph::new( - "^E explorer · Tab focus · i filter · Enter open/toggle · ↑/↓ scroll/tree · [/] expand above/below · ←/→ horizontal · Esc close", + "File-picker/explorer bindings · Tab focus · ↑/↓ scroll/tree · [/] expand above/below in Normal · :diff.close", ) .style(Style::default().fg(Color::DarkGray)), help, @@ -1218,6 +1349,24 @@ fn draw_diff( (after_area, Focus::After), ], }); + if let Some(picker) = &diff.files + && diff.files_visible + && picker.presentation == FilePresentation::Floating + { + let panel = floating_panel(area); + frame.render_widget(Clear, panel); + draw_file_list( + frame, + picker, + panel, + file_navigation_hint, + panels, + comparison.map(|comparison| comparison.files.as_slice()), + ); + } + if let Some(editor) = editor { + draw_diff_popups(frame, editor, area, document_caret, panels); + } } fn diff_status(status: FileStatus) -> &'static str { @@ -1236,9 +1385,10 @@ fn draw_diff_pane( area: Rect, before: bool, syntax: &SyntaxCache, -) { + viewport: (usize, usize), +) -> Rect { if area.width == 0 || area.height == 0 { - return; + return area; } let label = if before { "Before" } else { "After" }; let path = if before { @@ -1265,14 +1415,10 @@ fn draw_diff_pane( })), content, ); - return; + return content; } let source = if before { &file.before } else { &file.after }; - let left = if before { - diff.left_before - } else { - diff.left_after - }; + let (top, left) = viewport; let digits = source.len().max(1).ilog10() as usize + 1; let gutter = if usize::from(content.width) > digits + 2 { digits + 2 @@ -1284,12 +1430,12 @@ fn draw_diff_pane( .display_rows .iter() .enumerate() - .skip(diff.top) + .skip(top) .take(usize::from(content.height)) { let row_area = Rect::new( content.x, - content.y + (display_row - diff.top) as u16, + content.y + (display_row - top) as u16, content.width, 1, ); @@ -1380,6 +1526,12 @@ fn draw_diff_pane( left, ); } + Rect::new( + content.x + gutter as u16, + content.y, + width as u16, + content.height, + ) } fn floating_panel(area: Rect) -> Rect { @@ -1446,15 +1598,28 @@ fn draw_startup_diagnostic(frame: &mut Frame, diagnostic: &StartupDiagnostic, ar #[derive(Default)] struct DiffSyntaxCache { - key: Option<(u64, usize)>, + key: Option<(u64, usize, Option)>, revision: u64, + before_revision: u64, before: SyntaxCache, after: SyntaxCache, } impl DiffSyntaxCache { - fn update(&mut self, comparison_id: u64, file_index: usize, file: &ChangedFile) { - let key = Some((comparison_id, file_index)); + fn update( + &mut self, + comparison_id: u64, + file_index: usize, + file: &ChangedFile, + live_revision: Option, + ) { + let key = Some((comparison_id, file_index, live_revision)); + if self.key.map(|(id, index, _)| (id, index)) != Some((comparison_id, file_index)) { + self.before_revision = self + .before_revision + .checked_add(1) + .expect("diff syntax revisions exhausted"); + } if self.key != key { self.key = key; self.revision = self @@ -1465,7 +1630,7 @@ impl DiffSyntaxCache { self.before.update( &file.before, Some(file.old_path.as_deref().unwrap_or(&file.path)), - self.revision, + self.before_revision, ); self.after .update(&file.after, Some(&file.path), self.revision); @@ -1560,24 +1725,8 @@ impl WheelPanels { .find_map(|(area, focus)| area.contains(position).then_some(*focus)) }); if let Some(focus) = focus { - if session - .editor() - .diff() - .is_some_and(|diff| diff.phase == Phase::View) - { - for _ in 0..3 { - if session - .editor() - .diff() - .is_some_and(|diff| diff.focus == focus) - { - break; - } - session.handle(Input::Char('\t')); - } - } for _ in 0..3 { - session.handle(if down { Input::Down } else { Input::Up }); + session.scroll_diff(focus, down); } } return true; @@ -2329,6 +2478,8 @@ mod tests { let before: Vec = before.split('\n').map(str::to_owned).collect(); let after: Vec = after.split('\n').map(str::to_owned).collect(); ChangedFile { + before_eof: false, + after_eof: false, path: path.into(), old_path: None, status: FileStatus::Modified, @@ -2373,13 +2524,13 @@ mod tests { let mut file = syntax_diff_file("new.rs", "return 42", "fn main() {}"); file.old_path = Some("old.py".into()); let mut syntax = DiffSyntaxCache::default(); - syntax.update(1, 0, &file); + syntax.update(1, 0, &file, None); assert_eq!(syntax.before.language(), Some("Python")); assert_eq!(syntax.after.language(), Some("Rust")); assert_eq!(syntax.before.line(0)[0].kind, SyntaxKind::Keyword); assert_eq!(syntax.after.line(0)[0].kind, SyntaxKind::Keyword); file.path = "unknown.extension".into(); - syntax.update(1, 0, &file); + syntax.update(1, 0, &file, None); assert_eq!(syntax.after.language(), None); assert!(syntax.after.line(0).is_empty()); @@ -2479,6 +2630,8 @@ mod tests { diff.apply_result( id, Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison { + root: std::path::PathBuf::new(), + editable_paths: Default::default(), before: "old revision".into(), after: "Working checkout".into(), files, @@ -2489,6 +2642,8 @@ mod tests { fn deletion_file() -> ChangedFile { ChangedFile { + before_eof: false, + after_eof: false, path: "deleted-lines.txt".into(), old_path: None, status: FileStatus::Modified, @@ -2512,7 +2667,7 @@ mod tests { terminal .draw(|frame| { panels.begin_frame(); - draw_diff(frame, diff, frame.area(), "↑/↓", "h/j/k/l", panels); + draw_diff(frame, diff, frame.area(), "↑/↓", "h/j/k/l", panels, None); }) .unwrap(); } @@ -2572,6 +2727,8 @@ mod tests { after[30] = "let first_change = 900;".into(); after[60] = "let second_change = 901;".into(); let mut diff = diff_comparison(vec![ChangedFile { + before_eof: false, + after_eof: false, path: "excerpts.rs".into(), old_path: None, status: FileStatus::Modified, @@ -2588,7 +2745,8 @@ mod tests { notice: None, }]); diff.set_context_options(1, 3); - diff.handle(Input::Ctrl('e')); + diff.files_visible = false; + diff.focus = Focus::Before; let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(160, 24)).unwrap(); let mut panels = WheelPanels::default(); render_diff(&mut terminal, &diff, &mut panels); @@ -2676,6 +2834,8 @@ mod tests { #[test] fn diff_added_rows_hatch_missing_before_but_tint_real_empty_after_lines() { let mut diff = diff_comparison(vec![ChangedFile { + before_eof: false, + after_eof: false, path: "added.txt".into(), old_path: None, status: FileStatus::Added, @@ -2729,6 +2889,8 @@ mod tests { #[test] fn diff_changed_backgrounds_preserve_syntax_and_clipped_source_cells() { let mut diff = diff_comparison(vec![ChangedFile { + before_eof: false, + after_eof: false, path: "changed.rs".into(), old_path: None, status: FileStatus::Modified, @@ -2834,6 +2996,8 @@ mod tests { #[test] fn diff_view_changes_files_focus_and_keeps_horizontal_offsets_independent() { let added = ChangedFile { + before_eof: false, + after_eof: false, path: "new-name.txt".into(), old_path: Some("old-name.txt".into()), status: FileStatus::Renamed, @@ -2970,8 +3134,13 @@ mod tests { file }) .collect(); - let mut picker = - FilePicker::from_paths(PathBuf::new(), files.iter().map(|file| file.path.clone())); + let mut picker = FilePicker::from_paths( + PathBuf::new(), + files + .iter() + .enumerate() + .map(|(index, file)| (index, file.path.clone())), + ); picker.reveal_path(std::path::Path::new("chain/a/m.txt")); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 16)).unwrap(); for view in [FileView::Tree, FileView::List] { @@ -3023,6 +3192,14 @@ mod tests { } } } + // A reverted first file disappears without shifting the remaining status markers. + picker.replace_paths( + files + .iter() + .enumerate() + .skip(1) + .map(|(index, file)| (index, file.path.clone())), + ); picker.query = "m.txt".into(); picker.refilter(); terminal @@ -3061,7 +3238,9 @@ mod tests { let mut panels = WheelPanels::default(); render_diff(&mut terminal, &diff, &mut panels); let original = panels.diff.as_ref().unwrap().regions; - diff.handle(Input::Ctrl('e')); + diff.files_visible = false; + diff.focus = Focus::Before; + diff.files.as_mut().unwrap().focused = false; render_diff(&mut terminal, &diff, &mut panels); let hidden = panels.diff.as_ref().unwrap().regions; assert_eq!(hidden[0].0, Rect::default()); @@ -3071,7 +3250,9 @@ mod tests { assert_eq!(hidden[2].0.right(), 120); assert!(hidden[1].0.width + hidden[2].0.width > original[1].0.width + original[2].0.width); assert!(!screen_text(&terminal).contains("Explorer")); - diff.handle(Input::Ctrl('e')); + diff.files_visible = true; + diff.focus = Focus::Files; + diff.files.as_mut().unwrap().focused = true; render_diff(&mut terminal, &diff, &mut panels); assert_eq!(panels.diff.as_ref().unwrap().regions, original); assert_eq!(panels.files.unwrap().0, original[0].0); @@ -3093,6 +3274,8 @@ mod tests { assert!(screen_text(&terminal).contains("No changed files")); assert!(screen_text(&terminal).contains("Explorer · Tree")); let binary = diff_comparison(vec![ChangedFile { + before_eof: false, + after_eof: false, path: "image.bin".into(), old_path: None, status: FileStatus::Added, @@ -3104,6 +3287,8 @@ mod tests { render_diff(&mut terminal, &binary, &mut panels); assert!(screen_text(&terminal).contains("Binary file")); let blank = diff_comparison(vec![ChangedFile { + before_eof: false, + after_eof: false, path: "empty.txt".into(), old_path: None, status: FileStatus::Added, @@ -3134,7 +3319,7 @@ mod tests { } #[test] - fn actual_diff_draw_path_is_modal_and_escape_restores_document_viewport() { + fn immutable_diff_draw_path_preserves_document_until_explicit_close() { let directory = Directory::new(); let mut editor = Editor::open( Some(directory.file("source.txt", "original\nsecond")), @@ -3162,6 +3347,8 @@ mod tests { editor.apply_diff_result( id, Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison { + root: std::path::PathBuf::new(), + editable_paths: Default::default(), before: "old revision".into(), after: "Working checkout".into(), files: vec![deletion_file()], @@ -3177,7 +3364,7 @@ mod tests { assert_eq!(editor.lines(), original); editor.handle(Input::Escape); assert!(editor.diff().is_some()); - editor.handle(Input::Escape); + editor.execute("diff.close"); terminal .draw(|frame| viewports.draw(frame, &editor)) .unwrap(); @@ -3186,6 +3373,78 @@ mod tests { assert_eq!(editor.cursor(), cursor); } + #[test] + fn editable_after_renders_live_syntax_caret_visual_selection_and_command_prompt() { + let directory = Directory::new(); + let path = directory.file("live.rs", "let value = \"new\";"); + let mut editor = Editor::open(Some(path), 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::Enter); + let (id, _) = editor.take_diff_request().unwrap(); + editor.apply_diff_result( + id, + Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison { + root: directory.0.clone(), + editable_paths: [std::path::PathBuf::from("live.rs")].into(), + before: "baseline".into(), + after: "Working checkout".into(), + files: vec![syntax_diff_file( + "live.rs", + "let value = \"old\";", + "let value = \"new\";", + )], + })), + ); + editor.handle(Input::Char('\t')); + editor.handle(Input::Char('\t')); + assert!(editor.diff_editing()); + editor.handle(Input::Char('c')); + editor.handle(Input::Char('c')); + editor.handle(Input::Paste("// live comment".into())); + editor.handle(Input::Escape); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 14)).unwrap(); + let mut views = BufferViewports::default(); + terminal.draw(|frame| views.draw(frame, &editor)).unwrap(); + let after = views.panels.diff.as_ref().unwrap().regions[2].0; + let caret = terminal.get_cursor_position().unwrap(); + assert!(after.contains(caret)); + let text_x = after.x + 4; + assert_eq!(terminal.backend().buffer()[(text_x, caret.y)].symbol(), "/"); + assert_eq!( + terminal.backend().buffer()[(text_x, caret.y)].fg, + Color::DarkGray + ); + assert_eq!( + terminal.backend().buffer()[(text_x, caret.y)].bg, + Color::Rgb(24, 48, 32) + ); + editor.handle(Input::Home); + editor.handle(Input::Char('v')); + editor.handle(Input::Right); + terminal.draw(|frame| views.draw(frame, &editor)).unwrap(); + assert_eq!( + terminal.backend().buffer()[(text_x, caret.y)].bg, + Color::Blue + ); + editor.handle(Input::Escape); + editor.handle(Input::Char(':')); + for ch in "diff.close".chars() { + editor.handle(Input::Char(ch)); + } + terminal.draw(|frame| views.draw(frame, &editor)).unwrap(); + assert_eq!(terminal.get_cursor_position().unwrap().y, 13); + assert!(screen_text(&terminal).contains(":diff.close")); + editor.handle(Input::Enter); + assert!(editor.diff().is_none()); + assert_eq!(editor.lines(), ["// live comment"]); + assert!(editor.dirty()); + } + #[test] fn diff_wheel_routes_right_tree_and_both_panes_without_touching_document() { let directory = Directory::new(); @@ -3273,7 +3532,9 @@ mod tests { assert_eq!(diff.top, 0); assert_eq!(diff.file, 3); let selected = diff.files.as_ref().unwrap().selected; - session.handle(Input::Ctrl('e')); + session.handle(Input::Char(' ')); + session.handle(Input::Char('e')); + session.handle(Input::Ctrl('w')); terminal .draw(|frame| viewports.draw(frame, session.editor())) .unwrap(); @@ -3319,7 +3580,7 @@ mod tests { assert_eq!(viewports.offsets[&id], offset); assert_eq!(session.editor().cursor(), cursor); assert!(viewports.document.geometry.is_none()); - session.handle(Input::Escape); + session.execute("diff.close"); terminal .draw(|frame| viewports.draw(frame, session.editor())) .unwrap(); diff --git a/tests/diff.rs b/tests/diff.rs index cfdece0..0af723b 100644 --- a/tests/diff.rs +++ b/tests/diff.rs @@ -76,7 +76,7 @@ fn type_query(session: &mut EditorSession, text: &str) { } #[test] -fn typed_diff_compares_disk_not_unsaved_buffers_and_preserves_undo() { +fn typed_diff_edits_unsaved_working_after_and_preserves_undo_on_explicit_close() { let fixture = Fixture::new(); fixture.git(&["init", "-q"]); let source = fixture.0.join("repo/buffer.rs"); @@ -128,7 +128,12 @@ fn typed_diff_compares_disk_not_unsaved_buffers_and_preserves_undo() { let file = &comparison.files[0]; assert_eq!(file.path, Path::new("buffer.rs")); assert_eq!(file.before, ["before"]); - assert_eq!(file.after, ["after"]); + assert_eq!(file.after, ["uafter", ""]); + session.handle(Input::Char('\t')); + assert_eq!( + session.editor().diff().unwrap().focus, + vamp::diff::Focus::Before + ); for input in [ Input::Char('i'), Input::Char('x'), @@ -137,27 +142,41 @@ fn typed_diff_compares_disk_not_unsaved_buffers_and_preserves_undo() { session.handle(input); } session.handle(Input::Escape); - assert!( - !session - .editor() - .diff() - .unwrap() - .files - .as_ref() - .unwrap() - .searching - ); - session.handle(Input::Escape); - assert!(session.editor().diff().is_none()); + assert!(session.editor().diff().is_some()); assert_eq!(session.editor().context().text, "uafter\n"); - assert!(session.editor().dirty()); assert_eq!(session.editor().cursor(), cursor); assert_eq!(fs::read_to_string(&source).unwrap(), "after\n"); + + session.handle(Input::Char('\t')); + assert_eq!( + session.editor().diff().unwrap().focus, + vamp::diff::Focus::After + ); + for input in [Input::Char('i'), Input::Char('x'), Input::Escape] { + session.handle(input); + } + assert_eq!(session.editor().context().text, "xuafter\n"); + assert!(session.editor().diff().is_some()); + session.handle(Input::Char('u')); + assert_eq!(session.editor().context().text, "uafter\n"); + session.handle(Input::Char('U')); + assert_eq!(session.editor().context().text, "xuafter\n"); + session.execute("w"); + assert_eq!(fs::read_to_string(&source).unwrap(), "xuafter\n"); + assert!(!session.editor().dirty()); + session.handle(Input::Char('u')); + assert_eq!(session.editor().context().text, "uafter\n"); + assert!(session.editor().dirty()); + + session.execute("diff.close"); + assert!(session.editor().diff().is_none()); + assert_eq!(session.editor().context().text, "uafter\n"); session.handle(Input::Char('u')); assert_eq!(session.editor().context().text, "after\n"); + assert_eq!(fs::read_to_string(&source).unwrap(), "xuafter\n"); session.execute(":diff"); assert!(session.editor().diff().is_some()); - session.handle(Input::Escape); + session.execute("diff.close"); session.poll(); assert!(session.editor().diff().is_none()); assert_eq!(fixture.git(&["rev-parse", "HEAD"]), base); diff --git a/tests/repository.rs b/tests/repository.rs index 559c6a2..b6d3d7a 100644 --- a/tests/repository.rs +++ b/tests/repository.rs @@ -237,9 +237,15 @@ fn git_working_reads_disk_and_handles_ranges_ignored_files_binary_links_and_odd_ && r.category == Category::Tags && r.target == RevisionTarget::Commit(id.clone()))); let comparison = repository - .compare(&revision(&id, RepoKind::Git), &working(RepoKind::Git)) + .compare(&revision(&id, RepoKind::Git), &working(RepoKind::Git), &[]) .unwrap(); let text = changed(&comparison, Path::new("text")); + assert_eq!(comparison.root, fs::canonicalize(&root).unwrap()); + assert!(comparison.editable_paths.contains(Path::new("text"))); + assert!(comparison.editable_paths.contains(Path::new("empty-added"))); + for path in ["binary", "link", "removed", "type-switch"] { + assert!(!comparison.editable_paths.contains(Path::new(path))); + } assert_eq!(text.after, ["one", "two", "three"]); assert_eq!( ( @@ -338,15 +344,16 @@ fn git_working_reads_disk_and_handles_ranges_ignored_files_binary_links_and_odd_ assert_eq!(moved.before, moved.after); assert!(moved.hunks.is_empty()); let reverse = repository - .compare(&working(RepoKind::Git), &revision(&id, RepoKind::Git)) + .compare(&working(RepoKind::Git), &revision(&id, RepoKind::Git), &[]) .unwrap(); + assert!(reverse.editable_paths.is_empty()); assert_eq!( changed(&reverse, Path::new("added")).status, FileStatus::Removed ); assert!( repository - .compare(&working(RepoKind::Git), &working(RepoKind::Git)) + .compare(&working(RepoKind::Git), &working(RepoKind::Git), &[]) .unwrap() .files .is_empty() @@ -433,17 +440,22 @@ fn git_remote_refs_and_annotated_tags_are_discovered_before_fetch_and_history_st .success() ); let comparison = repository - .compare(&revision(&local, RepoKind::Git), &remote) + .compare( + &revision(&local, RepoKind::Git), + &remote, + &[(active.join("file"), "unsaved local text\n".into())], + ) .unwrap(); + assert!(comparison.editable_paths.is_empty()); assert_eq!(changed(&comparison, Path::new("file")).before, ["local"]); assert_eq!( changed(&comparison, Path::new("file")).after, ["remote tip"] ); - let comparison = repository.compare(&tag, &remote).unwrap(); + let comparison = repository.compare(&tag, &remote, &[]).unwrap(); assert_eq!(changed(&comparison, Path::new("file")).before, ["ancestor"]); let comparison = repository - .compare(&remote, &working(RepoKind::Git)) + .compare(&remote, &working(RepoKind::Git), &[]) .unwrap(); assert_eq!(changed(&comparison, Path::new("file")).after, ["local"]); let loaded = repository.fetch_remote("origin").unwrap(); @@ -457,7 +469,7 @@ fn git_remote_refs_and_annotated_tags_are_discovered_before_fetch_and_history_st assert_eq!(historic.label, ancestor); assert_eq!(historic.description, "remote ancestor needle"); let comparison = repository - .compare(historic, &revision(&local, RepoKind::Git)) + .compare(historic, &revision(&local, RepoKind::Git), &[]) .unwrap(); assert_eq!(changed(&comparison, Path::new("file")).before, ["ancestor"]); assert!( @@ -530,7 +542,7 @@ fn jj_catalog_and_working_comparisons_prefer_jj_and_never_snapshot_or_merge_oper .any(|r| r.label == "baseline" && r.category == Category::Bookmarks) ); let comparison = repository - .compare(baseline, &working(RepoKind::Jujutsu)) + .compare(baseline, &working(RepoKind::Jujutsu), &[]) .unwrap(); assert_eq!(changed(&comparison, Path::new("file")).after, ["disk"]); assert_eq!( @@ -652,7 +664,7 @@ fn jj_private_fetch_discovers_never_fetched_bookmark_and_searchable_remote_chang .any(|r| r.target == RevisionTarget::Commit(ancestor.clone())) ); let comparison = repository - .compare(&remote, &working(RepoKind::Jujutsu)) + .compare(&remote, &working(RepoKind::Jujutsu), &[]) .unwrap(); assert_eq!( changed(&comparison, Path::new("file")).before, @@ -670,7 +682,7 @@ fn jj_private_fetch_discovers_never_fetched_bookmark_and_searchable_remote_chang .unwrap(); assert!(historic.label.contains(&ancestor) && historic.label.contains(&change)); assert_eq!(historic.description, "remote historic needle"); - let comparison = repository.compare(historic, &remote).unwrap(); + let comparison = repository.compare(historic, &remote, &[]).unwrap(); assert_eq!( changed(&comparison, Path::new("file")).before, ["remote ancestor"] @@ -733,7 +745,11 @@ fn errors_are_explicit_and_do_not_modify_active_repository() { ); assert!( repository - .compare(&revision("--bad", RepoKind::Git), &working(RepoKind::Git)) + .compare( + &revision("--bad", RepoKind::Git), + &working(RepoKind::Git), + &[] + ) .unwrap_err() .contains("Invalid immutable commit") ); @@ -741,7 +757,8 @@ fn errors_are_explicit_and_do_not_modify_active_repository() { repository .compare( &revision(&"f".repeat(40), RepoKind::Git), - &working(RepoKind::Git) + &working(RepoKind::Git), + &[], ) .unwrap_err() .contains("unavailable") @@ -752,7 +769,7 @@ fn errors_are_explicit_and_do_not_modify_active_repository() { }; assert!( repository - .compare(&loader, &revision(&id, RepoKind::Git)) + .compare(&loader, &revision(&id, RepoKind::Git), &[]) .unwrap_err() .contains("history loader") ); @@ -792,7 +809,11 @@ fn unchanged_submodules_are_not_false_changes_and_changed_pointers_are_explicit( let mut repository = open(&root); assert!( repository - .compare(&revision(&baseline, RepoKind::Git), &working(RepoKind::Git)) + .compare( + &revision(&baseline, RepoKind::Git), + &working(RepoKind::Git), + &[] + ) .unwrap() .files .is_empty() @@ -804,7 +825,11 @@ fn unchanged_submodules_are_not_false_changes_and_changed_pointers_are_explicit( git(&root.join("nested"), &["checkout", "--detach", &second]); let state = disk_state(&root); let comparison = repository - .compare(&revision(&baseline, RepoKind::Git), &working(RepoKind::Git)) + .compare( + &revision(&baseline, RepoKind::Git), + &working(RepoKind::Git), + &[], + ) .unwrap(); let submodule = changed(&comparison, Path::new("nested")); assert_eq!(submodule.status, FileStatus::Modified); @@ -876,6 +901,7 @@ fn multiple_remotes_retain_tag_only_history_even_when_tag_names_collide() { .compare( &revision(&ids[0], RepoKind::Git), &revision(&ids[1], RepoKind::Git), + &[], ) .unwrap(); assert_eq!(changed(&comparison, Path::new("file")).before, ["one"]); @@ -989,6 +1015,7 @@ fn jj_conflicted_revisions_show_cli_projection_and_keep_ordinary_files_navigable .compare( &revision(&base, RepoKind::Jujutsu), &revision(&merged, RepoKind::Jujutsu), + &[], ) .unwrap(); let conflict = changed(&comparison, Path::new("conflict")); @@ -1009,6 +1036,7 @@ fn jj_conflicted_revisions_show_cli_projection_and_keep_ordinary_files_navigable .compare( &revision(&merged, RepoKind::Jujutsu), &working(RepoKind::Jujutsu), + &[], ) .unwrap(); assert!( @@ -1020,3 +1048,83 @@ fn jj_conflicted_revisions_show_cli_projection_and_keep_ordinary_files_navigable ); assert_eq!(disk_state(&root), state); } + +#[test] +fn unsaved_overlays_are_working_after_only_and_cannot_follow_nonregular_paths() { + let fixture = Fixture::new(); + let root = fs::canonicalize(fixture.dir("overlay")).unwrap(); + init_git(&root); + fs::create_dir(root.join("sub")).unwrap(); + fs::write(root.join("file"), "base\n").unwrap(); + fs::write(root.join("sub/file"), "nested\n").unwrap(); + fs::write(root.join("binary"), b"\0binary").unwrap(); + git(&root, &["add", "."]); + git(&root, &["commit", "-m", "base"]); + let id = git(&root, &["rev-parse", "HEAD"]); + let outside = fixture.dir("outside"); + fs::write(outside.join("file"), "outside\n").unwrap(); + symlink(&outside, root.join("linked")).unwrap(); + symlink("file", root.join("link")).unwrap(); + let state = disk_state(&root); + let buffers = vec![ + (root.join("file"), "unsaved\n".into()), + (root.join("binary"), "not binary\n".into()), + (root.join("missing"), "not on disk\n".into()), + (root.join("link"), "not a regular file\n".into()), + (root.join("linked/file"), "outside via link\n".into()), + (outside.join("file"), "outside\n".into()), + (root.join("sub/../../outside/file"), "escaped\n".into()), + (root.join(".git/config"), "metadata\n".into()), + ]; + let mut repository = open(&root.join("sub")); + let base = revision(&id, RepoKind::Git); + let checkout = working(RepoKind::Git); + let comparison = repository.compare(&base, &checkout, &buffers).unwrap(); + assert_eq!(comparison.root, fs::canonicalize(&root).unwrap()); + assert_eq!(changed(&comparison, Path::new("file")).after, ["unsaved"]); + assert!(comparison.editable_paths.contains(Path::new("file"))); + for path in [ + "binary", + "missing", + "link", + "linked", + "linked/file", + ".git/config", + ] { + assert!(!comparison.editable_paths.contains(Path::new(path))); + } + assert!( + !comparison + .files + .iter() + .any(|file| file.path == Path::new("binary") + || file.path == Path::new("missing") + || file.path == Path::new("linked/file")) + ); + let immutable = repository.compare(&checkout, &base, &buffers).unwrap(); + assert!(immutable.editable_paths.is_empty()); + assert!( + !immutable + .files + .iter() + .any(|file| file.path == Path::new("file")) + ); + assert!( + repository + .compare(&base, &base, &buffers) + .unwrap() + .files + .is_empty() + ); + let same_checkout = repository.compare(&checkout, &checkout, &buffers).unwrap(); + assert_eq!(changed(&same_checkout, Path::new("file")).before, ["base"]); + assert_eq!( + changed(&same_checkout, Path::new("file")).after, + ["unsaved"] + ); + assert_eq!(disk_state(&root), state); + assert_eq!( + fs::read_to_string(outside.join("file")).unwrap(), + "outside\n" + ); +}