feat: add local and remote repository diff views
This commit is contained in:
@@ -32,6 +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 |
|
| 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 |
|
| 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` |
|
| 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 |
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -10,13 +10,15 @@ One crate; no workspace or terminal-coordinate types in the editing backend:
|
|||||||
|
|
||||||
- `editor`: owns buffer lifecycle and per-buffer text, saved state, primary/selection set, modes, search, and undo/redo; session-wide clipboard policy, independent registers and owned-system fragment metadata, semantic native repeat, action metadata, command-palette and file-picker focus/dispatch, key notation, leader/count prefixes and binding resolution, modal paste policy, saving, command ownership, capabilities, and validated edit effects. Its low-level interface returns an `Invocation` when an external command is selected.
|
- `editor`: owns buffer lifecycle and per-buffer text, saved state, primary/selection set, modes, search, and undo/redo; session-wide clipboard policy, independent registers and owned-system fragment metadata, semantic native repeat, action metadata, command-palette and file-picker focus/dispatch, key notation, leader/count prefixes and binding resolution, modal paste policy, saving, command ownership, capabilities, and validated edit effects. Its low-level interface returns an `Invocation` when an external command is selected.
|
||||||
- `files`: owns the gitignore-aware directory/file catalog, authoritative OS paths, compact tree projection, query filtering, browse/search focus, expansion/selection restoration, navigation, and scan/error state shared by floating and docked presentations.
|
- `files`: owns the gitignore-aware directory/file catalog, authoritative OS paths, compact tree projection, query filtering, browse/search focus, expansion/selection restoration, navigation, and scan/error state shared by floating and docked presentations.
|
||||||
|
- `diff`: owns searchable Before/After selection, category and focus state, request freshness, read-only comparison DTOs, and aligned rows/shared vertical and independent horizontal offsets. It has no subprocess or terminal dependencies.
|
||||||
|
- `repository`: discovers jj/Git repositories and local/advertised remote revisions, builds disk snapshots and changed-file hunks, and owns isolated temporary comparison repositories with bounded, cancellable subprocesses. It never saves editor buffers or modifies active refs/checkouts.
|
||||||
- `clipboard`: owns native plain-text provider selection and bounded subprocess I/O; it never computes selections or editing offsets.
|
- `clipboard`: owns native plain-text provider selection and bounded subprocess I/O; it never computes selections or editing offsets.
|
||||||
- `syntax`: owns Tree-sitter grammar detection, per-buffer incremental trees, bundled highlight/local/injection queries, and syntax byte spans; it has no terminal color/geometry or editing dependency.
|
- `syntax`: owns Tree-sitter grammar detection, per-buffer incremental trees, bundled highlight/local/injection queries, and syntax byte spans; it has no terminal color/geometry or editing dependency.
|
||||||
- `markdown`: owns native Markdown link/reference resolution, local destination decoding, and heading fragment lookup through the installed block/inline grammars. It has no editor lifecycle, terminal, LSP, or external-process dependency.
|
- `markdown`: owns native Markdown link/reference resolution, local destination decoding, and heading fragment lookup through the installed block/inline grammars. It has no editor lifecycle, terminal, LSP, or external-process dependency.
|
||||||
- `plugin`: owns generic document snapshots/events, zero-based grapheme positions, diagnostics, frontend-independent semantic token kinds/modifiers and completion edit/item DTOs, and versioned asynchronous update contracts. Semantic token and completion updates are native-only, not part of the Ruby wire protocol; it has no LSP protocol types.
|
- `plugin`: owns generic document snapshots/events, zero-based grapheme positions, diagnostics, frontend-independent semantic token kinds/modifiers and completion edit/item DTOs, and versioned asynchronous update contracts. Semantic token and completion updates are native-only, not part of the Ruby wire protocol; it has no LSP protocol types.
|
||||||
- `lsp`: owns opt-in native server workers, bounded framed JSON-RPC, synchronization/capability negotiation, settings, UTF-16/file-URI conversion, definition/hover results, completion decoding, and semantic token requests/legend decoding. Its private configuration resolver supplies presets without restricting language IDs.
|
- `lsp`: owns opt-in native server workers, bounded framed JSON-RPC, synchronization/capability negotiation, settings, UTF-16/file-URI conversion, definition/hover results, completion decoding, and semantic token requests/legend decoding. Its private configuration resolver supplies presets without restricting language IDs.
|
||||||
- `snippet`: owns bounded, frontend-independent template parsing and expansion into plain replacement text and byte-relative tabstops with per-occurrence ancestry. It never edits documents or owns input policy.
|
- `snippet`: owns bounded, frontend-independent template parsing and expansion into plain replacement text and byte-relative tabstops with per-occurrence ancestry. It never edits documents or owns input policy.
|
||||||
- `session`: `EditorSession` owns the editor, native LSP, and Ruby hosts together. It loads configuration once, registers eligible plugins before installing shared bindings, tracks document open/change/save/close events, polls native and plugin results, validates versioned diagnostics/navigation/completion, and retires failed plugin ownership.
|
- `session`: `EditorSession` owns the editor, native LSP, and Ruby hosts together. It loads configuration once, registers eligible plugins before installing shared bindings, tracks document open/change/save/close events, polls native and plugin results, validates versioned diagnostics/navigation/completion, and retires failed plugin ownership. A cancellable diff worker owns repository I/O off the input thread; tagged results cannot reopen closed or superseded selections. Diff focus suppresses asynchronous editing/navigation effects without retiring healthy plugins.
|
||||||
- `tui`: translates terminal events to session input, renders read-only active-buffer state, keeps frontend-owned viewports and syntax caches by stable buffer ID, and advertises `text`, `styled_text`, and `diagnostics` capabilities. It maps semantic tokens and diagnostics to terminal styling and has no server/protocol logic or paste acceptance policy.
|
- `tui`: translates terminal events to session input, renders read-only active-buffer state, keeps frontend-owned viewports and syntax caches by stable buffer ID, and advertises `text`, `styled_text`, and `diagnostics` capabilities. It maps semantic tokens and diagnostics to terminal styling and has no server/protocol logic or paste acceptance policy.
|
||||||
- `ruby`: evaluates configuration and runs isolated plugin hosts. Protocol, deadlines, and process cleanup stay inside this module.
|
- `ruby`: evaluates configuration and runs isolated plugin hosts. Protocol, deadlines, and process cleanup stay inside this module.
|
||||||
- The executable selects paths and frontend capabilities, opens an editor session, and launches the TUI.
|
- The executable selects paths and frontend capabilities, opens an editor session, and launches the TUI.
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ Normal `dd` and `cc` delete/change complete rows, with leading counts. Bind `con
|
|||||||
| Normal | `save`, `quit`, `force_quit`, `save_and_quit`, `plugins` |
|
| Normal | `save`, `quit`, `force_quit`, `save_and_quit`, `plugins` |
|
||||||
| Normal | `edit`, `buffers`, `buffer`, `buffer_next`, `buffer_previous`, `buffer_delete`, `buffer_force_delete` |
|
| Normal | `edit`, `buffers`, `buffer`, `buffer_next`, `buffer_previous`, `buffer_delete`, `buffer_force_delete` |
|
||||||
| Normal | `follow_link` |
|
| Normal | `follow_link` |
|
||||||
|
| Normal | `diff` |
|
||||||
| Normal | `jump_back`, `jump_forward` |
|
| Normal | `jump_back`, `jump_forward` |
|
||||||
| Normal | `lsp.definition`, `lsp.diagnostics`, `lsp.hover` |
|
| Normal | `lsp.definition`, `lsp.diagnostics`, `lsp.hover` |
|
||||||
| Insert | `lsp.completion`, `snippet.next`, `snippet.previous` |
|
| Insert | `lsp.completion`, `snippet.next`, `snippet.previous` |
|
||||||
@@ -117,7 +118,7 @@ Bindings are resolved inside the editor module, not the terminal adapter. Bracke
|
|||||||
|
|
||||||
## Selection-list bindings
|
## Selection-list bindings
|
||||||
|
|
||||||
The palette, floating file picker, and focused docked explorer share `config.modes.selection`. It is a binding context, not a buffer editing mode. `config.core.down` moves to the next list item; `config.core.up` moves to the previous. Defaults are Ctrl+j/Down/Ctrl+n for next and Ctrl+k/Up for previous. Remap or disable any single key:
|
The palette, floating file picker, focused docked explorer, and diff selectors/navigation share `config.modes.selection`. It is a binding context, not a buffer editing mode. `config.core.down` moves to the next list item (or scrolls aligned diff rows); `config.core.up` moves to the previous. Defaults are Ctrl+j/Down/Ctrl+n for next and Ctrl+k/Up for previous. Remap or disable any single key:
|
||||||
|
|
||||||
```ruby
|
```ruby
|
||||||
config.bind "ctrl+f", config.core.down, mode: config.modes.selection
|
config.bind "ctrl+f", config.core.down, mode: config.modes.selection
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ Rust tests live alongside their modules; native LSP integration checks are in [`
|
|||||||
|
|
||||||
Clipboard checks use isolated providers rather than reading or overwriting the desktop clipboard.
|
Clipboard checks use isolated providers rather than reading or overwriting the desktop clipboard.
|
||||||
|
|
||||||
|
Diff checks cover staged category/filter navigation, stale request cancellation, row alignment, read-only buffer isolation, and rendered panes. Repository integration fixtures exercise Git and Git-backed jj, advertised but unfetched remote branches/bookmarks/tags, imported historical commit/change IDs, conflicts, unusual paths, binary/mode/link/submodule changes, and unchanged active metadata. Remote fixtures are local bare copies; they never push. Git fixture identities and commit dates are fixed.
|
||||||
|
|
||||||
## Performance and process limits
|
## Performance and process limits
|
||||||
|
|
||||||
- Undo uses full-buffer snapshots and movement scans the current line. This favors straightforward correctness over large-file performance.
|
- Undo uses full-buffer snapshots and movement scans the current line. This favors straightforward correctness over large-file performance.
|
||||||
@@ -52,5 +54,6 @@ Clipboard checks use isolated providers rather than reading or overwriting the d
|
|||||||
- PTY throughput alone cannot prove terminal presentation stays smooth: capture the editor and terminal while the user reproduces a pause. Synchronized-output commands must not reach the terminal during frame computation, even when individual frames and queued-input catch-up appear fast.
|
- PTY throughput alone cannot prove terminal presentation stays smooth: capture the editor and terminal while the user reproduces a pause. Synchronized-output commands must not reach the terminal during frame computation, even when individual frames and queued-input catch-up appear fast.
|
||||||
- Annotation coordinate validation fast-paths ASCII grapheme lengths using byte lengths with CRLF pair accounting, retaining Unicode segmentation for non-ASCII text. Validation remains atomic and still checks every token; large Unicode buffers or diagnostic batches can require foreground work.
|
- Annotation coordinate validation fast-paths ASCII grapheme lengths using byte lengths with CRLF pair accounting, retaining Unicode segmentation for non-ASCII text. Validation remains atomic and still checks every token; large Unicode buffers or diagnostic batches can require foreground work.
|
||||||
- Legacy synchronous plugin invocation may pause input for up to 500 ms before a host timeout. Async plugins opt into background replies; pending requests and transport queues are bounded. Config/plugin startup has a 2-second deadline per host. Buffer context/document snapshots must fit within a 4 MiB JSON frame; a too-large frame disables that plugin, not editing.
|
- Legacy synchronous plugin invocation may pause input for up to 500 ms before a host timeout. Async plugins opt into background replies; pending requests and transport queues are bounded. Config/plugin startup has a 2-second deadline per host. Buffer context/document snapshots must fit within a 4 MiB JSON frame; a too-large frame disables that plugin, not editing.
|
||||||
|
- Repository diff currently requires Unix subprocess-group cancellation and Git-backed jj (or Git alone). Each file is limited to 16 MiB; each subprocess output stream and Working/conflict snapshot to 64 MiB; each command to 60 seconds. Limit, transport, and non-commit-tag errors are explicit rather than silently truncated. Temporary comparison stores are removed when the diff closes.
|
||||||
|
|
||||||
See [syntax caches](architecture.md#syntax-caches) for the current full-tree highlighting ceiling.
|
See [syntax caches](architecture.md#syntax-caches) for the current full-tree highlighting ceiling.
|
||||||
|
|||||||
@@ -193,6 +193,26 @@ Clearing returns to the full catalog and reveals the selected file's ancestry in
|
|||||||
|
|
||||||
The project root is the working directory captured at startup. Scanning uses the `ignore` crate for nested `.gitignore` rules and negation; parent/global Git ignore rules are disabled. Hidden regular files and directories are included unless ignored, `.git`/`.jj` metadata is always excluded, and symlink files/directories are not followed. Files open using their original OS paths, not sanitized display labels. Root scan failures report an error; descendant/ignore errors retain reachable entries with an explicit incomplete-scan warning. A failed refresh keeps the prior catalog. There is no background watcher: refresh explicitly after external file changes.
|
The project root is the working directory captured at startup. Scanning uses the `ignore` crate for nested `.gitignore` rules and negation; parent/global Git ignore rules are disabled. Hidden regular files and directories are included unless ignored, `.git`/`.jj` metadata is always excluded, and symlink files/directories are not followed. Files open using their original OS paths, not sanitized display labels. Root scan failures report an error; descendant/ignore errors retain reachable entries with an explicit incomplete-scan warning. A failed refresh keeps the prior catalog. There is no background watcher: refresh explicitly after external file changes.
|
||||||
|
|
||||||
|
## Repository diff
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
| Selector input | Effect |
|
||||||
|
| --- | --- |
|
||||||
|
| Tab / Shift+Tab | Next / previous category |
|
||||||
|
| 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 |
|
||||||
|
|
||||||
|
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 a changed-file navigator, Before on the left, and After on the right. Added/deleted rows are aligned with blank counterparts. Tab/Shift+Tab cycles focus between Files, Before, and After. Up/Down or Ctrl+j/k selects files or scrolls both text panes together; Left/Right or Ctrl+h/l scrolls the focused text pane horizontally. Home/End goes to the first/last file or aligned row. Wheel input over a text pane scrolls both panes; over Files it navigates changed files. Esc closes and preserves the editor's buffers, cursors, unsaved text, and undo history. Typing and pasted text cannot edit either snapshot.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
## Command palette
|
## Command palette
|
||||||
|
|
||||||
Ctrl+p opens a searchable overlay without leaving the current mode. It lists each core action once, plus registered plugin commands, with their owner, effective bindings in the current mode, and eligible modes. Disabled or overridden bindings are not advertised; actions with no key remain available through the palette. Mode-ineligible entries are dimmed and cannot execute. Retired plugin commands disappear.
|
Ctrl+p opens a searchable overlay without leaving the current mode. It lists each core action once, plus registered plugin commands, with their owner, effective bindings in the current mode, and eligible modes. Disabled or overridden bindings are not advertised; actions with no key remain available through the palette. Mode-ineligible entries are dimmed and cannot execute. Retired plugin commands disappear.
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ Vamp.configure do |config|
|
|||||||
config.bind "g", "greet"
|
config.bind "g", "greet"
|
||||||
config.bind "leader g", "greet"
|
config.bind "leader g", "greet"
|
||||||
config.bind "leader q", config.core.quit
|
config.bind "leader q", config.core.quit
|
||||||
|
config.bind "leader v", config.core.diff
|
||||||
config.bind "ctrl+r", config.core.redo
|
config.bind "ctrl+r", config.core.redo
|
||||||
config.bind "leader r", config.core.repeat_last_edit
|
config.bind "leader r", config.core.repeat_last_edit
|
||||||
config.bind "ctrl+c", config.core.normal, mode: config.modes.insert
|
config.bind "ctrl+c", config.core.normal, mode: config.modes.insert
|
||||||
|
|||||||
+1065
File diff suppressed because it is too large
Load Diff
+163
@@ -1,4 +1,5 @@
|
|||||||
use crate::clipboard::{ClipboardMode, SystemClipboard};
|
use crate::clipboard::{ClipboardMode, SystemClipboard};
|
||||||
|
use crate::diff::{DiffState, Operation, Outcome, Phase};
|
||||||
use crate::files::{DockSide, FilePicker, FilePresentation, FileView};
|
use crate::files::{DockSide, FilePicker, FilePresentation, FileView};
|
||||||
use crate::plugin::{CompletionItem, Diagnostic, SemanticToken, SnippetTabstop, TextPosition};
|
use crate::plugin::{CompletionItem, Diagnostic, SemanticToken, SnippetTabstop, TextPosition};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
@@ -553,6 +554,7 @@ core_actions![
|
|||||||
"lsp.diagnostics",
|
"lsp.diagnostics",
|
||||||
),
|
),
|
||||||
("core.save", Action::Save, &[Mode::Normal], "w"),
|
("core.save", Action::Save, &[Mode::Normal], "w"),
|
||||||
|
("core.diff", Action::Diff, &[Mode::Normal], "diff"),
|
||||||
("core.quit", Action::Quit, &[Mode::Normal], "q"),
|
("core.quit", Action::Quit, &[Mode::Normal], "q"),
|
||||||
(
|
(
|
||||||
"core.force_quit",
|
"core.force_quit",
|
||||||
@@ -1061,6 +1063,9 @@ pub struct Editor {
|
|||||||
file_picker_view: FileView,
|
file_picker_view: FileView,
|
||||||
file_explorer_view: FileView,
|
file_explorer_view: FileView,
|
||||||
count: Option<usize>,
|
count: Option<usize>,
|
||||||
|
diff: Option<DiffState>,
|
||||||
|
next_diff_request: u64,
|
||||||
|
outstanding_diff_request: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Editor {
|
impl Editor {
|
||||||
@@ -1128,6 +1133,9 @@ impl Editor {
|
|||||||
file_picker_view: FileView::Tree,
|
file_picker_view: FileView::Tree,
|
||||||
file_explorer_view: FileView::Tree,
|
file_explorer_view: FileView::Tree,
|
||||||
count: None,
|
count: None,
|
||||||
|
diff: None,
|
||||||
|
next_diff_request: 0,
|
||||||
|
outstanding_diff_request: None,
|
||||||
};
|
};
|
||||||
editor.refresh_selection_navigation_hint();
|
editor.refresh_selection_navigation_hint();
|
||||||
editor
|
editor
|
||||||
@@ -1228,6 +1236,77 @@ impl Editor {
|
|||||||
&self.project_root
|
&self.project_root
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn diff(&self) -> Option<&DiffState> {
|
||||||
|
self.diff.as_ref()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn take_diff_request(&mut self) -> Option<(u64, Operation)> {
|
||||||
|
let operation = self.diff.as_mut()?.take_request()?;
|
||||||
|
self.next_diff_request = self
|
||||||
|
.next_diff_request
|
||||||
|
.checked_add(1)
|
||||||
|
.expect("diff request IDs exhausted");
|
||||||
|
let id = self.next_diff_request;
|
||||||
|
self.outstanding_diff_request = Some(id);
|
||||||
|
Some((id, operation))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn apply_diff_result(&mut self, id: u64, result: Result<Outcome, String>) -> bool {
|
||||||
|
if self.outstanding_diff_request != Some(id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
self.outstanding_diff_request = None;
|
||||||
|
let Some(diff) = &mut self.diff else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
diff.apply_result(result);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_diff(&mut self) {
|
||||||
|
self.count = None;
|
||||||
|
self.clear_leader();
|
||||||
|
self.palette = None;
|
||||||
|
self.hover = None;
|
||||||
|
self.startup_diagnostic = None;
|
||||||
|
self.blur_file_picker();
|
||||||
|
if self.active.mode == Mode::Command {
|
||||||
|
self.command_line.clear();
|
||||||
|
self.active.mode = Mode::Normal;
|
||||||
|
}
|
||||||
|
self.outstanding_diff_request = None;
|
||||||
|
self.diff = Some(DiffState::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_diff(&mut self, input: Input) {
|
||||||
|
// Escape and Tab always retain their modal close/back/focus semantics.
|
||||||
|
if !matches!(input, Input::Escape | Input::Char('\t') | Input::BackTab) {
|
||||||
|
match self.selection_bindings.get(&input) {
|
||||||
|
Some(Binding::Action(Action::Down)) => {
|
||||||
|
self.diff.as_mut().expect("active diff").navigate(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Some(Binding::Action(Action::Up)) => {
|
||||||
|
self.diff.as_mut().expect("active diff").navigate(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Some(Binding::Disabled) => return,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
self.outstanding_diff_request = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn buffer(&self, id: usize) -> Option<&Buffer> {
|
fn buffer(&self, id: usize) -> Option<&Buffer> {
|
||||||
if id == self.active.id {
|
if id == self.active.id {
|
||||||
Some(&self.active)
|
Some(&self.active)
|
||||||
@@ -2857,6 +2936,10 @@ impl Editor {
|
|||||||
Input::Ctrl(ch) => control_key(ch),
|
Input::Ctrl(ch) => control_key(ch),
|
||||||
input => input,
|
input => input,
|
||||||
};
|
};
|
||||||
|
if self.diff.is_some() {
|
||||||
|
self.handle_diff(input);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
if self.completion.is_some() {
|
if self.completion.is_some() {
|
||||||
let request = matches!(
|
let request = matches!(
|
||||||
self.bindings
|
self.bindings
|
||||||
@@ -3140,6 +3223,7 @@ impl Editor {
|
|||||||
| Action::BufferPrevious
|
| Action::BufferPrevious
|
||||||
| Action::BufferDelete
|
| Action::BufferDelete
|
||||||
| Action::BufferForceDelete
|
| Action::BufferForceDelete
|
||||||
|
| Action::Diff
|
||||||
| Action::LspDefinition
|
| Action::LspDefinition
|
||||||
| Action::LspHover
|
| Action::LspHover
|
||||||
| Action::LspDiagnostics => unreachable!("catalog Ex actions dispatch above"),
|
| Action::LspDiagnostics => unreachable!("catalog Ex actions dispatch above"),
|
||||||
@@ -3653,6 +3737,9 @@ impl Editor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn execute(&mut self, command: &str) -> Option<Invocation> {
|
pub fn execute(&mut self, command: &str) -> Option<Invocation> {
|
||||||
|
if self.diff.is_some() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
let command = command.trim().strip_prefix(':').unwrap_or(command.trim());
|
let command = command.trim().strip_prefix(':').unwrap_or(command.trim());
|
||||||
let (name, args) = command
|
let (name, args) = command
|
||||||
.split_once(char::is_whitespace)
|
.split_once(char::is_whitespace)
|
||||||
@@ -3694,6 +3781,14 @@ impl Editor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn execute_action(&mut self, entry: &CoreAction, args: &str) -> Option<Invocation> {
|
fn execute_action(&mut self, entry: &CoreAction, args: &str) -> Option<Invocation> {
|
||||||
|
if entry.action == Action::Diff {
|
||||||
|
if args.is_empty() {
|
||||||
|
self.open_diff();
|
||||||
|
} else {
|
||||||
|
self.set_message("The diff command takes no arguments");
|
||||||
|
}
|
||||||
|
return None;
|
||||||
|
}
|
||||||
self.prepare_command();
|
self.prepare_command();
|
||||||
let action = entry.action;
|
let action = entry.action;
|
||||||
let name = entry.ex.expect("Ex dispatcher requires an alias");
|
let name = entry.ex.expect("Ex dispatcher requires an alias");
|
||||||
@@ -3775,6 +3870,13 @@ impl Editor {
|
|||||||
/// to the primary caret: Insert runs once there, Replace replaces the buffer.
|
/// to the primary caret: Insert runs once there, Replace replaces the buffer.
|
||||||
/// Rejected responses and message-only effects retain the complete set.
|
/// Rejected responses and message-only effects retain the complete set.
|
||||||
pub fn apply(&mut self, effects: Vec<Effect>) -> Result<(), String> {
|
pub fn apply(&mut self, effects: Vec<Effect>) -> Result<(), String> {
|
||||||
|
if self.diff.is_some()
|
||||||
|
&& effects
|
||||||
|
.iter()
|
||||||
|
.any(|effect| matches!(effect, Effect::Insert(_) | Effect::Replace(_)))
|
||||||
|
{
|
||||||
|
return Err("Diff view is read-only".into());
|
||||||
|
}
|
||||||
let mut total = 0usize;
|
let mut total = 0usize;
|
||||||
for effect in &effects {
|
for effect in &effects {
|
||||||
let text = match effect {
|
let text = match effect {
|
||||||
@@ -6274,6 +6376,67 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn diff_preserves_parked_buffers_visual_carets_and_insert_history() {
|
||||||
|
let directory = Directory::new();
|
||||||
|
let a = directory.path("a.txt");
|
||||||
|
let b = directory.path("b.txt");
|
||||||
|
fs::write(&a, "alpha").unwrap();
|
||||||
|
fs::write(&b, "beta").unwrap();
|
||||||
|
let mut editor = Editor::open(Some(a), 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);
|
||||||
|
type_text(&mut editor, "vl");
|
||||||
|
let carets = editor.carets();
|
||||||
|
let selections = editor.selections();
|
||||||
|
let documents = editor
|
||||||
|
.documents()
|
||||||
|
.map(|document| (document.id, document.revision, document.lines.to_vec()))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
editor.execute("diff");
|
||||||
|
for input in [
|
||||||
|
Input::Paste("bad".into()),
|
||||||
|
Input::Delete,
|
||||||
|
Input::Char('d'),
|
||||||
|
Input::Enter,
|
||||||
|
] {
|
||||||
|
editor.handle(input);
|
||||||
|
}
|
||||||
|
editor.handle(Input::Escape);
|
||||||
|
assert_eq!(editor.mode(), Mode::Visual);
|
||||||
|
assert_eq!(editor.carets(), carets);
|
||||||
|
assert_eq!(editor.selections(), selections);
|
||||||
|
assert_eq!(
|
||||||
|
editor
|
||||||
|
.documents()
|
||||||
|
.map(|document| (document.id, document.revision, document.lines.to_vec()))
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
documents
|
||||||
|
);
|
||||||
|
editor.handle(Input::Escape);
|
||||||
|
editor.handle(Input::Char('u'));
|
||||||
|
assert_eq!(editor.context().text, "beta");
|
||||||
|
editor.execute("buffer 1");
|
||||||
|
editor.handle(Input::Char('u'));
|
||||||
|
assert_eq!(editor.context().text, "alpha");
|
||||||
|
|
||||||
|
editor.handle(Input::Char('i'));
|
||||||
|
type_text(&mut editor, "start");
|
||||||
|
let caret = editor.cursor();
|
||||||
|
editor.execute("diff");
|
||||||
|
editor.handle(Input::Paste("ignored".into()));
|
||||||
|
editor.handle(Input::Escape);
|
||||||
|
assert_eq!(editor.mode(), Mode::Insert);
|
||||||
|
assert_eq!(editor.cursor(), caret);
|
||||||
|
type_text(&mut editor, "finish");
|
||||||
|
editor.handle(Input::Escape);
|
||||||
|
editor.handle(Input::Char('u'));
|
||||||
|
assert_eq!(editor.context().text, "alpha");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn line_actions_counted_delete_restores_caret_and_pastes_whole_rows() {
|
fn line_actions_counted_delete_restores_caret_and_pastes_whole_rows() {
|
||||||
let mut editor = editor();
|
let mut editor = editor();
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
pub mod clipboard;
|
pub mod clipboard;
|
||||||
|
pub mod diff;
|
||||||
pub mod editor;
|
pub mod editor;
|
||||||
pub mod files;
|
pub mod files;
|
||||||
pub mod lsp;
|
pub mod lsp;
|
||||||
mod lsp_config;
|
mod lsp_config;
|
||||||
mod markdown;
|
mod markdown;
|
||||||
pub mod plugin;
|
pub mod plugin;
|
||||||
|
pub mod repository;
|
||||||
pub mod ruby;
|
pub mod ruby;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
mod snippet;
|
mod snippet;
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ Commands: :w [PATH], :q, :q!, :wq [PATH], :plugins, :PLUGIN [ARGS]
|
|||||||
Buffers: :edit PATH, :buffers, :buffer N, :bnext, :bprev, :bdelete[!] [N]
|
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
|
:q refuses any dirty buffer; :wq saves active and quits only if all are clean
|
||||||
Markdown links: :follow_link (primary caret; local files only)
|
Markdown links: :follow_link (primary caret; local files only)
|
||||||
|
Diff: :diff (Normal); choose Before then After; Working checkout means disk, not unsaved buffers
|
||||||
|
Tab/Shift+Tab categories/focus; type to search IDs/refs; arrows navigate/scroll; Esc back/close
|
||||||
|
Remote refs/history load into temporary comparison repos without changing active refs/checkout
|
||||||
|
|
||||||
Ruby config: $XDG_CONFIG_HOME/vamp/config.rb or ~/.config/vamp/config.rb
|
Ruby config: $XDG_CONFIG_HOME/vamp/config.rb or ~/.config/vamp/config.rb
|
||||||
Set VAMP_RUBY to choose the Ruby executable.
|
Set VAMP_RUBY to choose the Ruby executable.
|
||||||
|
|||||||
+1393
File diff suppressed because it is too large
Load Diff
+245
-1
@@ -2,18 +2,125 @@ use std::{
|
|||||||
collections::BTreeMap,
|
collections::BTreeMap,
|
||||||
io,
|
io,
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
|
sync::{
|
||||||
|
Arc,
|
||||||
|
atomic::{AtomicBool, AtomicU64, Ordering},
|
||||||
|
mpsc::{self, Receiver, SyncSender},
|
||||||
|
},
|
||||||
|
thread::{self, JoinHandle},
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
diff::{Operation as DiffOperation, Outcome as DiffOutcome},
|
||||||
editor::{Cursor, Editor, Effect, Input, Invocation, Mode},
|
editor::{Cursor, Editor, Effect, Input, Invocation, Mode},
|
||||||
lsp::Lsp,
|
lsp::Lsp,
|
||||||
plugin::{
|
plugin::{
|
||||||
DocumentEvent, DocumentEventKind, DocumentSnapshot, PluginEvent, PluginUpdate,
|
DocumentEvent, DocumentEventKind, DocumentSnapshot, PluginEvent, PluginUpdate,
|
||||||
TextPosition, UpdateEffect,
|
TextPosition, UpdateEffect,
|
||||||
},
|
},
|
||||||
|
repository::Repository,
|
||||||
ruby::{Config, RubyPlugin, load_config},
|
ruby::{Config, RubyPlugin, load_config},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type DiffResult = (u64, Result<DiffOutcome, String>);
|
||||||
|
|
||||||
|
struct DiffWorker {
|
||||||
|
requests: Option<SyncSender<(u64, DiffOperation)>>,
|
||||||
|
results: Option<Receiver<DiffResult>>,
|
||||||
|
cancelled: Arc<AtomicBool>,
|
||||||
|
stopped: Arc<AtomicBool>,
|
||||||
|
cancelled_request: Arc<AtomicU64>,
|
||||||
|
thread: Option<JoinHandle<()>>,
|
||||||
|
pending: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DiffWorker {
|
||||||
|
fn start(root: PathBuf) -> Result<Self, String> {
|
||||||
|
let (requests, incoming) = mpsc::sync_channel::<(u64, DiffOperation)>(1);
|
||||||
|
let (outgoing, results) = mpsc::sync_channel(1);
|
||||||
|
let cancelled = Arc::new(AtomicBool::new(false));
|
||||||
|
let stopped = Arc::new(AtomicBool::new(false));
|
||||||
|
let cancelled_request = Arc::new(AtomicU64::new(0));
|
||||||
|
let cancelled_id = Arc::clone(&cancelled_request);
|
||||||
|
let stop = Arc::clone(&stopped);
|
||||||
|
let cancellation = Arc::clone(&cancelled);
|
||||||
|
let thread = thread::Builder::new()
|
||||||
|
.name("vamp-diff".into())
|
||||||
|
.spawn(move || {
|
||||||
|
let mut repository = None;
|
||||||
|
while let Ok((id, operation)) = incoming.recv() {
|
||||||
|
if stop.load(Ordering::Acquire) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
cancellation.store(false, Ordering::Release);
|
||||||
|
if stop.load(Ordering::Acquire) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if cancelled_id.load(Ordering::Acquire) == id {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let result = (|| {
|
||||||
|
if repository.is_none() {
|
||||||
|
repository = Some(Repository::open(&root, Arc::clone(&cancellation))?);
|
||||||
|
}
|
||||||
|
let repository = repository.as_mut().expect("repository opened");
|
||||||
|
match operation {
|
||||||
|
DiffOperation::Discover => {
|
||||||
|
repository.catalog().map(DiffOutcome::Catalog)
|
||||||
|
}
|
||||||
|
DiffOperation::FetchRemote(remote) => {
|
||||||
|
repository.fetch_remote(&remote).map(DiffOutcome::Catalog)
|
||||||
|
}
|
||||||
|
DiffOperation::Compare { before, after } => repository
|
||||||
|
.compare(&before, &after)
|
||||||
|
.map(DiffOutcome::Comparison),
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
if stop.load(Ordering::Acquire) || outgoing.send((id, result)).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.map_err(|error| format!("Cannot start diff worker: {error}"))?;
|
||||||
|
Ok(Self {
|
||||||
|
requests: Some(requests),
|
||||||
|
results: Some(results),
|
||||||
|
cancelled,
|
||||||
|
stopped,
|
||||||
|
cancelled_request,
|
||||||
|
thread: Some(thread),
|
||||||
|
pending: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request(&mut self, id: u64, operation: DiffOperation) -> Result<(), String> {
|
||||||
|
self.requests
|
||||||
|
.as_ref()
|
||||||
|
.expect("live diff worker has a sender")
|
||||||
|
.try_send((id, operation))
|
||||||
|
.map_err(|error| match error {
|
||||||
|
mpsc::TrySendError::Full(_) => "Diff worker is already busy".to_owned(),
|
||||||
|
mpsc::TrySendError::Disconnected(_) => {
|
||||||
|
"Diff worker stopped unexpectedly".to_owned()
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
self.pending = Some(id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for DiffWorker {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.stopped.store(true, Ordering::Release);
|
||||||
|
self.cancelled.store(true, Ordering::Release);
|
||||||
|
self.requests.take();
|
||||||
|
self.results.take();
|
||||||
|
if let Some(thread) = self.thread.take() {
|
||||||
|
let _ = thread.join();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct TrackedDocument {
|
struct TrackedDocument {
|
||||||
snapshot: DocumentSnapshot,
|
snapshot: DocumentSnapshot,
|
||||||
save_revision: u64,
|
save_revision: u64,
|
||||||
@@ -45,6 +152,7 @@ pub struct EditorSession {
|
|||||||
pending_hover: bool,
|
pending_hover: bool,
|
||||||
pending_completion: bool,
|
pending_completion: bool,
|
||||||
hover_owner: Option<String>,
|
hover_owner: Option<String>,
|
||||||
|
diff_worker: Option<DiffWorker>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EditorSession {
|
impl EditorSession {
|
||||||
@@ -135,6 +243,7 @@ impl EditorSession {
|
|||||||
pending_hover: false,
|
pending_hover: false,
|
||||||
pending_completion: false,
|
pending_completion: false,
|
||||||
hover_owner: None,
|
hover_owner: None,
|
||||||
|
diff_worker: None,
|
||||||
};
|
};
|
||||||
session.synchronize_documents();
|
session.synchronize_documents();
|
||||||
Ok(session)
|
Ok(session)
|
||||||
@@ -152,6 +261,7 @@ impl EditorSession {
|
|||||||
self.dispatch(invocation);
|
self.dispatch(invocation);
|
||||||
self.synchronize_documents();
|
self.synchronize_documents();
|
||||||
}
|
}
|
||||||
|
self.start_diff_request();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn execute(&mut self, command: &str) {
|
pub fn execute(&mut self, command: &str) {
|
||||||
@@ -162,6 +272,82 @@ impl EditorSession {
|
|||||||
self.dispatch(invocation);
|
self.dispatch(invocation);
|
||||||
self.synchronize_documents();
|
self.synchronize_documents();
|
||||||
}
|
}
|
||||||
|
self.start_diff_request();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_diff_request(&mut self) {
|
||||||
|
if self.editor.diff().is_none() {
|
||||||
|
self.diff_worker = None;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if self.editor.diff().is_some_and(|diff| !diff.loading)
|
||||||
|
&& let Some(worker) = &mut self.diff_worker
|
||||||
|
&& let Some(id) = worker.pending.take()
|
||||||
|
{
|
||||||
|
worker.cancelled_request.store(id, Ordering::Release);
|
||||||
|
worker.cancelled.store(true, Ordering::Release);
|
||||||
|
}
|
||||||
|
let Some((id, operation)) = self.editor.take_diff_request() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if self.diff_worker.is_none() {
|
||||||
|
let root = self
|
||||||
|
.editor
|
||||||
|
.path()
|
||||||
|
.and_then(Path::parent)
|
||||||
|
.filter(|path| !path.as_os_str().is_empty())
|
||||||
|
.unwrap_or_else(|| self.editor.project_root())
|
||||||
|
.to_path_buf();
|
||||||
|
match DiffWorker::start(root) {
|
||||||
|
Ok(worker) => self.diff_worker = Some(worker),
|
||||||
|
Err(error) => {
|
||||||
|
self.editor.apply_diff_result(id, Err(error));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Err(error) = self
|
||||||
|
.diff_worker
|
||||||
|
.as_mut()
|
||||||
|
.expect("diff worker started")
|
||||||
|
.request(id, operation)
|
||||||
|
{
|
||||||
|
self.editor.apply_diff_result(id, Err(error));
|
||||||
|
self.diff_worker = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn poll_diff(&mut self) -> bool {
|
||||||
|
self.start_diff_request();
|
||||||
|
let mut changed = false;
|
||||||
|
while let Some(worker) = self.diff_worker.as_ref() {
|
||||||
|
let result = worker
|
||||||
|
.results
|
||||||
|
.as_ref()
|
||||||
|
.expect("live diff worker has results")
|
||||||
|
.try_recv();
|
||||||
|
match result {
|
||||||
|
Ok((id, result)) => {
|
||||||
|
if let Some(worker) = &mut self.diff_worker
|
||||||
|
&& worker.pending == Some(id)
|
||||||
|
{
|
||||||
|
worker.pending = None;
|
||||||
|
}
|
||||||
|
changed |= self.editor.apply_diff_result(id, result);
|
||||||
|
}
|
||||||
|
Err(mpsc::TryRecvError::Empty) => break,
|
||||||
|
Err(mpsc::TryRecvError::Disconnected) => {
|
||||||
|
if let Some(id) = self.diff_worker.as_ref().and_then(|worker| worker.pending) {
|
||||||
|
changed |= self
|
||||||
|
.editor
|
||||||
|
.apply_diff_result(id, Err("Diff worker stopped unexpectedly".into()));
|
||||||
|
}
|
||||||
|
self.diff_worker = None;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
changed
|
||||||
}
|
}
|
||||||
|
|
||||||
fn dispatch(&mut self, invocation: Invocation) {
|
fn dispatch(&mut self, invocation: Invocation) {
|
||||||
@@ -368,6 +554,9 @@ impl EditorSession {
|
|||||||
request_id: Option<u64>,
|
request_id: Option<u64>,
|
||||||
source: InvocationSource,
|
source: InvocationSource,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
|
if self.editor.diff().is_some() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
self.editor.buffer_id() == source.buffer_id
|
self.editor.buffer_id() == source.buffer_id
|
||||||
&& self.source_is_current(source)
|
&& self.source_is_current(source)
|
||||||
&& (owner != "core"
|
&& (owner != "core"
|
||||||
@@ -617,7 +806,10 @@ impl EditorSession {
|
|||||||
let Some(source) = source else {
|
let Some(source) = source else {
|
||||||
return Err("plugin response has an unknown invocation ID".into());
|
return Err("plugin response has an unknown invocation ID".into());
|
||||||
};
|
};
|
||||||
if !self.source_is_current(source) || self.editor.buffer_id() != source.buffer_id {
|
if !self.source_is_current(source)
|
||||||
|
|| self.editor.buffer_id() != source.buffer_id
|
||||||
|
|| self.editor.diff().is_some()
|
||||||
|
{
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
if effects
|
if effects
|
||||||
@@ -641,6 +833,7 @@ impl EditorSession {
|
|||||||
/// Drains ready plugin work without waiting for command or server responses.
|
/// Drains ready plugin work without waiting for command or server responses.
|
||||||
pub fn poll(&mut self) -> bool {
|
pub fn poll(&mut self) -> bool {
|
||||||
let mut changed = self.synchronize_documents();
|
let mut changed = self.synchronize_documents();
|
||||||
|
changed |= self.poll_diff();
|
||||||
for update in self.lsp.poll() {
|
for update in self.lsp.poll() {
|
||||||
match self.apply_update("core", update) {
|
match self.apply_update("core", update) {
|
||||||
Ok(applied) => changed |= applied,
|
Ok(applied) => changed |= applied,
|
||||||
@@ -3076,4 +3269,55 @@ end
|
|||||||
assert_eq!(invokes[0]["column"], 2);
|
assert_eq!(invokes[0]["column"], 2);
|
||||||
assert_eq!(invokes[0]["revision"], 0);
|
assert_eq!(invokes[0]["revision"], 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pending_plugin_edits_do_not_modify_diff_buffers_or_disable_the_plugin() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let source = fixture.file("buffer.txt", "original");
|
||||||
|
let started = fixture.0.join("started");
|
||||||
|
let release = fixture.0.join("release");
|
||||||
|
fixture.file(
|
||||||
|
"delayed.rb",
|
||||||
|
r#"Vamp.plugin "delayed", requires: ["text"], async: true
|
||||||
|
Vamp.command "slow" do |editor, _|
|
||||||
|
File.write(Vamp.options.fetch("started"), "ready")
|
||||||
|
sleep 0.001 until File.exist?(Vamp.options.fetch("release"))
|
||||||
|
editor.insert "late edit"
|
||||||
|
end
|
||||||
|
Vamp.command "ping" do |editor, _|
|
||||||
|
editor.message "still alive"
|
||||||
|
end
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
let config = fixture.file(
|
||||||
|
"config.rb",
|
||||||
|
&format!(
|
||||||
|
"Vamp.configure do |config|\n config.clipboard_mode = 'separate'\n config.plugin 'delayed.rb', options: {{started: {}, release: {}}}\nend\n",
|
||||||
|
serde_json::to_string(&started.to_string_lossy()).unwrap(),
|
||||||
|
serde_json::to_string(&release.to_string_lossy()).unwrap(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
let mut session =
|
||||||
|
EditorSession::open(Some(source.clone()), vec!["text".into()], Some(&config)).unwrap();
|
||||||
|
session.execute("slow");
|
||||||
|
drain_until(&mut session, |_| started.exists());
|
||||||
|
session.execute("diff");
|
||||||
|
assert!(session.editor().diff().is_some());
|
||||||
|
fs::write(&release, "release").unwrap();
|
||||||
|
// Wait for transport consumption, then assert the consumer-visible result.
|
||||||
|
drain_until(&mut session, |session| {
|
||||||
|
session
|
||||||
|
.requests
|
||||||
|
.get("delayed")
|
||||||
|
.is_none_or(|requests| requests.pending.is_empty())
|
||||||
|
});
|
||||||
|
assert_eq!(session.editor().context().text, "original");
|
||||||
|
assert_eq!(fs::read_to_string(&source).unwrap(), "original");
|
||||||
|
session.handle(Input::Escape);
|
||||||
|
session.execute("ping");
|
||||||
|
drain_until(&mut session, |session| {
|
||||||
|
session.editor().message() == "still alive"
|
||||||
|
});
|
||||||
|
assert_eq!(session.editor().context().text, "original");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+870
-2
@@ -25,13 +25,14 @@ use ratatui::{
|
|||||||
backend::CrosstermBackend,
|
backend::CrosstermBackend,
|
||||||
layout::{Constraint, Layout, Position, Rect},
|
layout::{Constraint, Layout, Position, Rect},
|
||||||
style::{Color, Modifier, Style},
|
style::{Color, Modifier, Style},
|
||||||
text::Line,
|
text::{Line, Span},
|
||||||
widgets::{Block, Borders, Clear, Paragraph},
|
widgets::{Block, Borders, Clear, Paragraph},
|
||||||
};
|
};
|
||||||
use unicode_segmentation::UnicodeSegmentation;
|
use unicode_segmentation::UnicodeSegmentation;
|
||||||
use unicode_width::UnicodeWidthStr;
|
use unicode_width::UnicodeWidthStr;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
diff::{ChangedFile, DiffState, FileStatus, Focus, Phase, RowKind},
|
||||||
editor::{
|
editor::{
|
||||||
Completion, Cursor, Editor, Hover, Input, Mode, Palette, Selection, StartupDiagnostic,
|
Completion, Cursor, Editor, Hover, Input, Mode, Palette, Selection, StartupDiagnostic,
|
||||||
},
|
},
|
||||||
@@ -414,8 +415,18 @@ fn draw(
|
|||||||
) {
|
) {
|
||||||
panels.begin_frame();
|
panels.begin_frame();
|
||||||
document.geometry = None;
|
document.geometry = None;
|
||||||
syntax.update(editor.lines(), editor.path(), editor.text_revision());
|
|
||||||
let area = frame.area();
|
let area = frame.area();
|
||||||
|
if let Some(diff) = editor.diff() {
|
||||||
|
draw_diff(
|
||||||
|
frame,
|
||||||
|
diff,
|
||||||
|
area,
|
||||||
|
editor.selection_navigation_hint(),
|
||||||
|
panels,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
syntax.update(editor.lines(), editor.path(), editor.text_revision());
|
||||||
if area.width == 0 || area.height == 0 {
|
if area.width == 0 || area.height == 0 {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -870,6 +881,461 @@ fn draw(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct DiffGeometry {
|
||||||
|
regions: [(Rect, Focus); 3],
|
||||||
|
}
|
||||||
|
|
||||||
|
fn diff_panel(frame: &mut Frame, area: Rect, title: &str, focused: bool) -> Rect {
|
||||||
|
if area.width < 4 || area.height < 4 {
|
||||||
|
return area;
|
||||||
|
}
|
||||||
|
let block = Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(Style::default().fg(if focused {
|
||||||
|
Color::Cyan
|
||||||
|
} else {
|
||||||
|
Color::DarkGray
|
||||||
|
}))
|
||||||
|
.title(title);
|
||||||
|
let inner = block.inner(area);
|
||||||
|
frame.render_widget(block, area);
|
||||||
|
inner
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_diff(
|
||||||
|
frame: &mut Frame,
|
||||||
|
diff: &DiffState,
|
||||||
|
area: Rect,
|
||||||
|
navigation_hint: &str,
|
||||||
|
panels: &mut WheelPanels,
|
||||||
|
) {
|
||||||
|
if area.width == 0 || area.height == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
frame.render_widget(Clear, area);
|
||||||
|
if diff.phase != Phase::View {
|
||||||
|
let panel = floating_panel(area);
|
||||||
|
let stage = if diff.phase == Phase::Before {
|
||||||
|
"Before"
|
||||||
|
} else {
|
||||||
|
"After"
|
||||||
|
};
|
||||||
|
let title = format!(" Diff · Choose {stage} ");
|
||||||
|
let content = diff_panel(frame, panel, &title, true);
|
||||||
|
let notice = diff.error.as_deref().or_else(|| {
|
||||||
|
if diff.loading {
|
||||||
|
Some(if diff.after.is_some() {
|
||||||
|
"Loading comparison…"
|
||||||
|
} else {
|
||||||
|
"Loading repository…"
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
diff.catalog
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|catalog| catalog.notice.as_deref())
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let [
|
||||||
|
stage_area,
|
||||||
|
tabs_area,
|
||||||
|
query_area,
|
||||||
|
notice_area,
|
||||||
|
pinned_area,
|
||||||
|
results,
|
||||||
|
help,
|
||||||
|
] = Layout::vertical([
|
||||||
|
Constraint::Length(u16::from(content.height >= 5)),
|
||||||
|
Constraint::Length(u16::from(content.height >= 4)),
|
||||||
|
Constraint::Length(1),
|
||||||
|
Constraint::Length(u16::from(notice.is_some() && content.height >= 3)),
|
||||||
|
Constraint::Length(u16::from(content.height >= 2)),
|
||||||
|
Constraint::Fill(1),
|
||||||
|
Constraint::Length(u16::from(content.height >= 7)),
|
||||||
|
])
|
||||||
|
.areas(content);
|
||||||
|
let before = diff
|
||||||
|
.before
|
||||||
|
.as_ref()
|
||||||
|
.map_or("not selected", |revision| revision.label.as_str());
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(format!("Choose {stage} · Before: {}", display_text(before))),
|
||||||
|
stage_area,
|
||||||
|
);
|
||||||
|
if let Some(catalog) = &diff.catalog {
|
||||||
|
let tabs: Vec<Span> = catalog
|
||||||
|
.kind
|
||||||
|
.categories()
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, category)| {
|
||||||
|
Span::styled(
|
||||||
|
format!(" {} ", category.label()),
|
||||||
|
if index == diff.category {
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Black)
|
||||||
|
.bg(Color::Cyan)
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(Color::DarkGray)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
frame.render_widget(Paragraph::new(Line::from(tabs)), tabs_area);
|
||||||
|
}
|
||||||
|
let query = format!("> {}", display_text(&diff.query));
|
||||||
|
let width = usize::from(query_area.width);
|
||||||
|
let query_width = display_column(&query, usize::MAX);
|
||||||
|
let offset = query_width.saturating_sub(width.saturating_sub(1));
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(visible_line(&query, offset, width))
|
||||||
|
.style(Style::default().fg(Color::Yellow)),
|
||||||
|
query_area,
|
||||||
|
);
|
||||||
|
if query_area.width > 0 && query_area.height > 0 {
|
||||||
|
frame.set_cursor_position((query_area.x + (query_width - offset) as u16, query_area.y));
|
||||||
|
}
|
||||||
|
if let Some(notice) = notice {
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(display_text(notice)).style(Style::default().fg(
|
||||||
|
if diff.error.is_some() {
|
||||||
|
Color::Red
|
||||||
|
} else {
|
||||||
|
Color::Yellow
|
||||||
|
},
|
||||||
|
)),
|
||||||
|
notice_area,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let mut entries = diff.entries();
|
||||||
|
if let Some(working) = entries.next() {
|
||||||
|
let label = format!(
|
||||||
|
"{} {} · pinned",
|
||||||
|
if diff.selected == 0 { ">" } else { " " },
|
||||||
|
display_text(&working.label)
|
||||||
|
);
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(visible_line(&label, 0, usize::from(pinned_area.width))).style(
|
||||||
|
if diff.selected == 0 {
|
||||||
|
Style::default()
|
||||||
|
.bg(Color::Blue)
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(Color::Cyan)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
pinned_area,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let row_height = if results.width >= 50 && results.height >= 4 {
|
||||||
|
2
|
||||||
|
} else {
|
||||||
|
1
|
||||||
|
};
|
||||||
|
let count = usize::from(results.height) / row_height;
|
||||||
|
let first = diff.selected.saturating_sub(count.saturating_sub(1)).max(1);
|
||||||
|
let mut lines = Vec::with_capacity(count * row_height);
|
||||||
|
for (index, revision) in entries
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, revision)| (index + 1, revision))
|
||||||
|
.skip(first - 1)
|
||||||
|
.take(count)
|
||||||
|
{
|
||||||
|
let style = if index == diff.selected {
|
||||||
|
Style::default()
|
||||||
|
.bg(Color::Blue)
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
|
} else {
|
||||||
|
Style::default()
|
||||||
|
};
|
||||||
|
let label = format!(
|
||||||
|
"{} {}",
|
||||||
|
if index == diff.selected { ">" } else { " " },
|
||||||
|
display_text(&revision.label)
|
||||||
|
);
|
||||||
|
let width = usize::from(results.width);
|
||||||
|
if row_height == 2 {
|
||||||
|
lines.push(Line::styled(visible_line(&label, 0, width), style));
|
||||||
|
lines.push(Line::styled(
|
||||||
|
visible_line(
|
||||||
|
&format!(" {}", display_text(&revision.description)),
|
||||||
|
0,
|
||||||
|
width,
|
||||||
|
),
|
||||||
|
style.fg(Color::DarkGray),
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
lines.push(Line::styled(
|
||||||
|
visible_line(
|
||||||
|
&format!("{label} · {}", display_text(&revision.description)),
|
||||||
|
0,
|
||||||
|
width,
|
||||||
|
),
|
||||||
|
style,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if lines.is_empty() && !diff.loading {
|
||||||
|
frame.render_widget(Paragraph::new("No matching revisions"), results);
|
||||||
|
} else {
|
||||||
|
frame.render_widget(Paragraph::new(lines), results);
|
||||||
|
}
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(format!(
|
||||||
|
"{navigation_hint} · Tab categories · type search · Enter select · Esc back/cancel"
|
||||||
|
))
|
||||||
|
.style(Style::default().fg(Color::DarkGray)),
|
||||||
|
help,
|
||||||
|
);
|
||||||
|
panels.diff = Some(DiffGeometry {
|
||||||
|
regions: [
|
||||||
|
(panel, Focus::Files),
|
||||||
|
(Rect::default(), Focus::Before),
|
||||||
|
(Rect::default(), Focus::After),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let comparison = diff.comparison.as_ref();
|
||||||
|
let selected = comparison.and_then(|comparison| comparison.files.get(diff.file));
|
||||||
|
let notice = diff.error.as_deref().or_else(|| {
|
||||||
|
if diff.loading {
|
||||||
|
Some("Loading comparison…")
|
||||||
|
} else {
|
||||||
|
selected.and_then(|file| file.notice.as_deref())
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let [header, notice_area, workspace, help] = Layout::vertical([
|
||||||
|
Constraint::Length(1),
|
||||||
|
Constraint::Length(u16::from(notice.is_some() && area.height >= 3)),
|
||||||
|
Constraint::Fill(1),
|
||||||
|
Constraint::Length(u16::from(area.height >= 3)),
|
||||||
|
])
|
||||||
|
.areas(area);
|
||||||
|
let (before, after) = comparison.map_or(("", ""), |comparison| {
|
||||||
|
(comparison.before.as_str(), comparison.after.as_str())
|
||||||
|
});
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(format!(
|
||||||
|
"Diff · {} → {} · {:?} focus · read-only",
|
||||||
|
display_text(before),
|
||||||
|
display_text(after),
|
||||||
|
diff.focus
|
||||||
|
))
|
||||||
|
.style(Style::default().fg(Color::Black).bg(Color::Cyan)),
|
||||||
|
header,
|
||||||
|
);
|
||||||
|
if let Some(notice) = notice {
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(display_text(notice)).style(Style::default().fg(
|
||||||
|
if diff.error.is_some() {
|
||||||
|
Color::Red
|
||||||
|
} else {
|
||||||
|
Color::Yellow
|
||||||
|
},
|
||||||
|
)),
|
||||||
|
notice_area,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let navigator_width = if workspace.width >= 40 {
|
||||||
|
(workspace.width / 4).min(32)
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
let [navigator, panes] =
|
||||||
|
Layout::horizontal([Constraint::Length(navigator_width), Constraint::Fill(1)])
|
||||||
|
.areas(workspace);
|
||||||
|
let [before_area, after_area] =
|
||||||
|
Layout::horizontal([Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)]).areas(panes);
|
||||||
|
let navigator_inner = diff_panel(
|
||||||
|
frame,
|
||||||
|
navigator,
|
||||||
|
" Changed files ",
|
||||||
|
diff.focus == Focus::Files,
|
||||||
|
);
|
||||||
|
if let Some(comparison) = comparison
|
||||||
|
&& navigator_inner.width > 0
|
||||||
|
&& navigator_inner.height > 0
|
||||||
|
{
|
||||||
|
let height = usize::from(navigator_inner.height);
|
||||||
|
let first = diff.file.saturating_sub(height.saturating_sub(1));
|
||||||
|
let lines: Vec<Line> = comparison
|
||||||
|
.files
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.skip(first)
|
||||||
|
.take(height)
|
||||||
|
.map(|(index, file)| {
|
||||||
|
let label = format!(
|
||||||
|
"{} {} {}",
|
||||||
|
if index == diff.file { ">" } else { " " },
|
||||||
|
diff_status(file.status),
|
||||||
|
display_text(&file.path.to_string_lossy())
|
||||||
|
);
|
||||||
|
let style = if index == diff.file {
|
||||||
|
Style::default()
|
||||||
|
.bg(if diff.focus == Focus::Files {
|
||||||
|
Color::Blue
|
||||||
|
} else {
|
||||||
|
Color::DarkGray
|
||||||
|
})
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
|
} else {
|
||||||
|
Style::default()
|
||||||
|
};
|
||||||
|
Line::styled(
|
||||||
|
visible_line(&label, 0, usize::from(navigator_inner.width)),
|
||||||
|
style,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
frame.render_widget(Paragraph::new(lines), navigator_inner);
|
||||||
|
}
|
||||||
|
if let Some(file) = selected {
|
||||||
|
draw_diff_pane(frame, diff, file, before_area, true);
|
||||||
|
draw_diff_pane(frame, diff, file, after_area, false);
|
||||||
|
} else {
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(if diff.loading {
|
||||||
|
"Loading comparison…"
|
||||||
|
} else {
|
||||||
|
"No changed files"
|
||||||
|
}),
|
||||||
|
panes,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(
|
||||||
|
"Tab focus · ↑/↓ scroll/files · ←/→ horizontal · Home/End · Esc close · read-only",
|
||||||
|
)
|
||||||
|
.style(Style::default().fg(Color::DarkGray)),
|
||||||
|
help,
|
||||||
|
);
|
||||||
|
panels.diff = Some(DiffGeometry {
|
||||||
|
regions: [
|
||||||
|
(navigator, Focus::Files),
|
||||||
|
(before_area, Focus::Before),
|
||||||
|
(after_area, Focus::After),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn diff_status(status: FileStatus) -> &'static str {
|
||||||
|
match status {
|
||||||
|
FileStatus::Added => "A",
|
||||||
|
FileStatus::Removed => "D",
|
||||||
|
FileStatus::Modified => "M",
|
||||||
|
FileStatus::Renamed => "R",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_diff_pane(
|
||||||
|
frame: &mut Frame,
|
||||||
|
diff: &DiffState,
|
||||||
|
file: &ChangedFile,
|
||||||
|
area: Rect,
|
||||||
|
before: bool,
|
||||||
|
) {
|
||||||
|
if area.width == 0 || area.height == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let label = if before { "Before" } else { "After" };
|
||||||
|
let path = if before {
|
||||||
|
file.old_path.as_ref().unwrap_or(&file.path)
|
||||||
|
} else {
|
||||||
|
&file.path
|
||||||
|
};
|
||||||
|
let title = format!(
|
||||||
|
" {label} · {} · {} ",
|
||||||
|
diff_status(file.status),
|
||||||
|
display_text(&path.to_string_lossy())
|
||||||
|
);
|
||||||
|
let focus = if before { Focus::Before } else { Focus::After };
|
||||||
|
let content = diff_panel(frame, area, &title, diff.focus == focus);
|
||||||
|
if diff.rows.is_empty() {
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(
|
||||||
|
file.notice
|
||||||
|
.as_deref()
|
||||||
|
.map(display_text)
|
||||||
|
.unwrap_or_else(|| "Empty file".into()),
|
||||||
|
),
|
||||||
|
content,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let source = if before { &file.before } else { &file.after };
|
||||||
|
let left = if before {
|
||||||
|
diff.left_before
|
||||||
|
} else {
|
||||||
|
diff.left_after
|
||||||
|
};
|
||||||
|
let digits = source.len().max(1).ilog10() as usize + 1;
|
||||||
|
let gutter = if usize::from(content.width) > digits + 2 {
|
||||||
|
digits + 2
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
let width = usize::from(content.width).saturating_sub(gutter);
|
||||||
|
let lines: Vec<Line> = diff
|
||||||
|
.rows
|
||||||
|
.iter()
|
||||||
|
.skip(diff.top)
|
||||||
|
.take(usize::from(content.height))
|
||||||
|
.map(|row| {
|
||||||
|
let index = if before { row.before } else { row.after };
|
||||||
|
let changed = matches!(row.kind, RowKind::Changed)
|
||||||
|
|| (before && row.kind == RowKind::Removed)
|
||||||
|
|| (!before && row.kind == RowKind::Added);
|
||||||
|
let color = if changed {
|
||||||
|
if before { Color::Red } else { Color::Green }
|
||||||
|
} else {
|
||||||
|
Color::Reset
|
||||||
|
};
|
||||||
|
let Some(index) = index else {
|
||||||
|
return Line::from("");
|
||||||
|
};
|
||||||
|
let Some(text) = source.get(index) else {
|
||||||
|
return Line::from("");
|
||||||
|
};
|
||||||
|
let text = if text.chars().any(|ch| ch.is_control() && ch != '\t') {
|
||||||
|
Cow::Owned(
|
||||||
|
text.chars()
|
||||||
|
.map(|ch| {
|
||||||
|
if ch.is_control() && ch != '\t' {
|
||||||
|
'�'
|
||||||
|
} else {
|
||||||
|
ch
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<String>(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Cow::Borrowed(text.as_str())
|
||||||
|
};
|
||||||
|
let mut spans = Vec::with_capacity(2);
|
||||||
|
if gutter > 0 {
|
||||||
|
let marker = if changed {
|
||||||
|
if before { '-' } else { '+' }
|
||||||
|
} else {
|
||||||
|
' '
|
||||||
|
};
|
||||||
|
spans.push(Span::styled(
|
||||||
|
format!("{:>digits$}{marker} ", index + 1),
|
||||||
|
Style::default().fg(if changed { color } else { Color::DarkGray }),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
spans.push(Span::styled(
|
||||||
|
visible_line(&text, left, width),
|
||||||
|
Style::default().fg(color),
|
||||||
|
));
|
||||||
|
Line::from(spans)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
frame.render_widget(Paragraph::new(lines), content);
|
||||||
|
}
|
||||||
|
|
||||||
fn floating_panel(area: Rect) -> Rect {
|
fn floating_panel(area: Rect) -> Rect {
|
||||||
let [_, column, _] = Layout::horizontal([
|
let [_, column, _] = Layout::horizontal([
|
||||||
Constraint::Fill(1),
|
Constraint::Fill(1),
|
||||||
@@ -939,6 +1405,7 @@ struct WheelPanels {
|
|||||||
hover_syntax: SyntaxCache,
|
hover_syntax: SyntaxCache,
|
||||||
files: Option<(Rect, usize, usize)>,
|
files: Option<(Rect, usize, usize)>,
|
||||||
manual_files: Option<FileWheelOffset>,
|
manual_files: Option<FileWheelOffset>,
|
||||||
|
diff: Option<DiffGeometry>,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct FileWheelOffset {
|
struct FileWheelOffset {
|
||||||
@@ -973,6 +1440,7 @@ impl WheelPanels {
|
|||||||
self.hover = None;
|
self.hover = None;
|
||||||
self.completion = None;
|
self.completion = None;
|
||||||
self.files = None;
|
self.files = None;
|
||||||
|
self.diff = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keyboard/paste may change the catalog without changing its length.
|
// Keyboard/paste may change the catalog without changing its length.
|
||||||
@@ -1008,6 +1476,37 @@ impl WheelPanels {
|
|||||||
_ => return false,
|
_ => return false,
|
||||||
};
|
};
|
||||||
let editor = session.editor();
|
let editor = session.editor();
|
||||||
|
if editor.diff().is_some() {
|
||||||
|
let position = Position::new(mouse.column, mouse.row);
|
||||||
|
let focus = self.diff.as_ref().and_then(|geometry| {
|
||||||
|
geometry
|
||||||
|
.regions
|
||||||
|
.iter()
|
||||||
|
.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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if editor.palette().is_some() || editor.startup_diagnostic().is_some() {
|
if editor.palette().is_some() || editor.startup_diagnostic().is_some() {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -1640,6 +2139,375 @@ mod tests {
|
|||||||
.join("\n")
|
.join("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn diff_catalog() -> crate::diff::Catalog {
|
||||||
|
use crate::diff::{Catalog, Category, RepoKind, Revision, RevisionTarget};
|
||||||
|
let mut entries = vec![Revision {
|
||||||
|
target: RevisionTarget::Working,
|
||||||
|
label: "Working checkout".into(),
|
||||||
|
description: "On-disk files".into(),
|
||||||
|
category: Category::Commits,
|
||||||
|
}];
|
||||||
|
entries.extend((0..20).map(|index| Revision {
|
||||||
|
target: RevisionTarget::Commit(format!("commit-{index:02}")),
|
||||||
|
label: format!("commit-{index:02}"),
|
||||||
|
description: if index == 19 {
|
||||||
|
"needle description".into()
|
||||||
|
} else {
|
||||||
|
"ordinary change".into()
|
||||||
|
},
|
||||||
|
category: Category::Commits,
|
||||||
|
}));
|
||||||
|
entries.push(Revision {
|
||||||
|
target: RevisionTarget::Commit("branch-tip".into()),
|
||||||
|
label: "feature-branch".into(),
|
||||||
|
description: "branch description".into(),
|
||||||
|
category: Category::Branches,
|
||||||
|
});
|
||||||
|
Catalog {
|
||||||
|
kind: RepoKind::Git,
|
||||||
|
entries,
|
||||||
|
notice: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn diff_comparison(files: Vec<ChangedFile>) -> DiffState {
|
||||||
|
let mut diff = DiffState::new();
|
||||||
|
diff.take_request();
|
||||||
|
diff.apply_result(Ok(crate::diff::Outcome::Catalog(diff_catalog())));
|
||||||
|
diff.handle(Input::Enter);
|
||||||
|
diff.handle(Input::Enter);
|
||||||
|
diff.take_request();
|
||||||
|
diff.apply_result(Ok(crate::diff::Outcome::Comparison(
|
||||||
|
crate::diff::Comparison {
|
||||||
|
before: "old revision".into(),
|
||||||
|
after: "Working checkout".into(),
|
||||||
|
files,
|
||||||
|
},
|
||||||
|
)));
|
||||||
|
diff
|
||||||
|
}
|
||||||
|
|
||||||
|
fn deletion_file() -> ChangedFile {
|
||||||
|
ChangedFile {
|
||||||
|
path: "deleted-lines.txt".into(),
|
||||||
|
old_path: None,
|
||||||
|
status: FileStatus::Modified,
|
||||||
|
before: ["keep", "gone", "tail"].map(String::from).to_vec(),
|
||||||
|
after: ["keep", "tail"].map(String::from).to_vec(),
|
||||||
|
hunks: vec![crate::diff::Hunk {
|
||||||
|
before_start: 1,
|
||||||
|
before_len: 1,
|
||||||
|
after_start: 1,
|
||||||
|
after_len: 0,
|
||||||
|
}],
|
||||||
|
notice: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_diff(
|
||||||
|
terminal: &mut Terminal<ratatui::backend::TestBackend>,
|
||||||
|
diff: &DiffState,
|
||||||
|
panels: &mut WheelPanels,
|
||||||
|
) {
|
||||||
|
terminal
|
||||||
|
.draw(|frame| {
|
||||||
|
panels.begin_frame();
|
||||||
|
draw_diff(frame, diff, frame.area(), "↑/↓", panels);
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn diff_picker_keeps_working_pinned_while_windowing_searching_and_changing_stage() {
|
||||||
|
let mut diff = DiffState::new();
|
||||||
|
diff.take_request();
|
||||||
|
diff.apply_result(Ok(crate::diff::Outcome::Catalog(diff_catalog())));
|
||||||
|
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 16)).unwrap();
|
||||||
|
let mut panels = WheelPanels::default();
|
||||||
|
diff.handle(Input::End);
|
||||||
|
render_diff(&mut terminal, &diff, &mut panels);
|
||||||
|
let text = screen_text(&terminal);
|
||||||
|
assert!(text.contains("Choose Before"));
|
||||||
|
assert!(text.contains("Working checkout · pinned"));
|
||||||
|
assert!(text.contains("> commit-19"));
|
||||||
|
assert!(!text.contains("commit-00"));
|
||||||
|
for ch in "needle".chars() {
|
||||||
|
diff.handle(Input::Char(ch));
|
||||||
|
}
|
||||||
|
diff.handle(Input::Down);
|
||||||
|
render_diff(&mut terminal, &diff, &mut panels);
|
||||||
|
let text = screen_text(&terminal);
|
||||||
|
assert!(text.contains("> needle"));
|
||||||
|
assert!(text.contains("Working checkout · pinned"));
|
||||||
|
assert!(text.contains("> commit-19"));
|
||||||
|
diff.handle(Input::Enter);
|
||||||
|
render_diff(&mut terminal, &diff, &mut panels);
|
||||||
|
assert!(screen_text(&terminal).contains("Choose After · Before: commit-19"));
|
||||||
|
diff.handle(Input::Escape);
|
||||||
|
render_diff(&mut terminal, &diff, &mut panels);
|
||||||
|
assert!(screen_text(&terminal).contains("> needle"));
|
||||||
|
assert!(screen_text(&terminal).contains("> commit-19"));
|
||||||
|
diff.handle(Input::Ctrl('u'));
|
||||||
|
diff.handle(Input::Char('\t'));
|
||||||
|
render_diff(&mut terminal, &diff, &mut panels);
|
||||||
|
let text = screen_text(&terminal);
|
||||||
|
assert!(text.contains("feature-branch"));
|
||||||
|
assert!(!text.contains("ordinary change"));
|
||||||
|
assert!(text.contains("Working checkout · pinned"));
|
||||||
|
let buffer = terminal.backend().buffer();
|
||||||
|
assert!(
|
||||||
|
buffer
|
||||||
|
.content
|
||||||
|
.iter()
|
||||||
|
.any(|cell| cell.symbol() == "B" && cell.bg == Color::Cyan)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn diff_view_aligns_deletion_holes_numbers_colors_and_shared_vertical_scroll() {
|
||||||
|
let mut diff = diff_comparison(vec![deletion_file()]);
|
||||||
|
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 12)).unwrap();
|
||||||
|
let mut panels = WheelPanels::default();
|
||||||
|
render_diff(&mut terminal, &diff, &mut panels);
|
||||||
|
let geometry = panels.diff.as_ref().unwrap();
|
||||||
|
let before = geometry.regions[1].0;
|
||||||
|
let after = geometry.regions[2].0;
|
||||||
|
let buffer = terminal.backend().buffer();
|
||||||
|
let y = before.y + 2;
|
||||||
|
assert_eq!(buffer[(before.x + 4, y)].symbol(), "g");
|
||||||
|
assert_eq!(buffer[(before.x + 4, y)].fg, Color::Red);
|
||||||
|
assert_eq!(buffer[(before.x + 2, y)].symbol(), "-");
|
||||||
|
for x in after.x + 1..after.right() - 1 {
|
||||||
|
assert_eq!(buffer[(x, y)].symbol(), " ");
|
||||||
|
}
|
||||||
|
assert_eq!(buffer[(before.x + 1, y + 1)].symbol(), "3");
|
||||||
|
assert_eq!(buffer[(after.x + 1, y + 1)].symbol(), "2");
|
||||||
|
assert_eq!(buffer[(before.x + 4, y + 1)].symbol(), "t");
|
||||||
|
assert_eq!(buffer[(after.x + 4, y + 1)].symbol(), "t");
|
||||||
|
diff.handle(Input::Char('\t'));
|
||||||
|
diff.handle(Input::Down);
|
||||||
|
render_diff(&mut terminal, &diff, &mut panels);
|
||||||
|
let buffer = terminal.backend().buffer();
|
||||||
|
assert_eq!(buffer[(before.x + 4, before.y + 1)].symbol(), "g");
|
||||||
|
assert_eq!(buffer[(after.x + 4, after.y + 1)].symbol(), " ");
|
||||||
|
assert_eq!(buffer[(before.x, before.y)].fg, Color::Cyan);
|
||||||
|
assert_eq!(buffer[(after.x, after.y)].fg, Color::DarkGray);
|
||||||
|
assert!(screen_text(&terminal).contains("Before focus"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn diff_added_rows_leave_before_blank_and_show_green_after_numbers() {
|
||||||
|
let diff = diff_comparison(vec![ChangedFile {
|
||||||
|
path: "added.txt".into(),
|
||||||
|
old_path: None,
|
||||||
|
status: FileStatus::Added,
|
||||||
|
before: vec![],
|
||||||
|
after: vec!["first addition".into(), "second addition".into()],
|
||||||
|
hunks: vec![crate::diff::Hunk {
|
||||||
|
before_start: 0,
|
||||||
|
before_len: 0,
|
||||||
|
after_start: 0,
|
||||||
|
after_len: 2,
|
||||||
|
}],
|
||||||
|
notice: None,
|
||||||
|
}]);
|
||||||
|
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 12)).unwrap();
|
||||||
|
let mut panels = WheelPanels::default();
|
||||||
|
render_diff(&mut terminal, &diff, &mut panels);
|
||||||
|
let geometry = panels.diff.as_ref().unwrap();
|
||||||
|
let before = geometry.regions[1].0;
|
||||||
|
let after = geometry.regions[2].0;
|
||||||
|
let buffer = terminal.backend().buffer();
|
||||||
|
for offset in 0..2 {
|
||||||
|
let y = before.y + 1 + offset;
|
||||||
|
for x in before.x + 1..before.right() - 1 {
|
||||||
|
assert_eq!(buffer[(x, y)].symbol(), " ");
|
||||||
|
}
|
||||||
|
assert_eq!(buffer[(after.x + 1, y)].symbol(), (offset + 1).to_string());
|
||||||
|
assert_eq!(buffer[(after.x + 2, y)].symbol(), "+");
|
||||||
|
assert_eq!(buffer[(after.x + 4, y)].fg, Color::Green);
|
||||||
|
}
|
||||||
|
assert!(screen_text(&terminal).contains("> A added.txt"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn diff_view_changes_files_focus_and_keeps_horizontal_offsets_independent() {
|
||||||
|
let added = ChangedFile {
|
||||||
|
path: "new-name.txt".into(),
|
||||||
|
old_path: Some("old-name.txt".into()),
|
||||||
|
status: FileStatus::Renamed,
|
||||||
|
before: vec!["\t界e\u{301}OLD".into()],
|
||||||
|
after: vec!["\t界e\u{301}NEW".into()],
|
||||||
|
hunks: vec![crate::diff::Hunk {
|
||||||
|
before_start: 0,
|
||||||
|
before_len: 1,
|
||||||
|
after_start: 0,
|
||||||
|
after_len: 1,
|
||||||
|
}],
|
||||||
|
notice: None,
|
||||||
|
};
|
||||||
|
let mut diff = diff_comparison(vec![deletion_file(), added]);
|
||||||
|
diff.handle(Input::Down);
|
||||||
|
diff.handle(Input::Char('\t'));
|
||||||
|
for _ in 0..4 {
|
||||||
|
diff.handle(Input::Right);
|
||||||
|
}
|
||||||
|
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 12)).unwrap();
|
||||||
|
let mut panels = WheelPanels::default();
|
||||||
|
render_diff(&mut terminal, &diff, &mut panels);
|
||||||
|
let text = screen_text(&terminal);
|
||||||
|
assert!(text.contains("> R new-name.txt"));
|
||||||
|
assert!(text.contains("Before · R · old-name.txt"));
|
||||||
|
assert!(text.contains("After · R · new-name.txt"));
|
||||||
|
let geometry = panels.diff.as_ref().unwrap();
|
||||||
|
let before = geometry.regions[1].0;
|
||||||
|
let after = geometry.regions[2].0;
|
||||||
|
let buffer = terminal.backend().buffer();
|
||||||
|
assert_eq!(buffer[(before.x + 4, before.y + 1)].symbol(), "界");
|
||||||
|
assert_eq!(buffer[(before.x + 6, before.y + 1)].symbol(), "e\u{301}");
|
||||||
|
assert_eq!(buffer[(after.x + 4, after.y + 1)].symbol(), " ");
|
||||||
|
assert_eq!(buffer[(after.x + 8, after.y + 1)].symbol(), "界");
|
||||||
|
assert_eq!(buffer[(after.x + 8, after.y + 1)].fg, Color::Green);
|
||||||
|
diff.handle(Input::Char('\t'));
|
||||||
|
render_diff(&mut terminal, &diff, &mut panels);
|
||||||
|
assert!(screen_text(&terminal).contains("After focus"));
|
||||||
|
assert_eq!(
|
||||||
|
terminal.backend().buffer()[(after.x, after.y)].fg,
|
||||||
|
Color::Cyan
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn diff_notices_empty_comparisons_and_tiny_layouts_remain_renderable() {
|
||||||
|
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 12)).unwrap();
|
||||||
|
let mut panels = WheelPanels::default();
|
||||||
|
let mut diff = DiffState::new();
|
||||||
|
render_diff(&mut terminal, &diff, &mut panels);
|
||||||
|
assert!(screen_text(&terminal).contains("Loading repository"));
|
||||||
|
diff.take_request();
|
||||||
|
diff.apply_result(Err("permission denied".into()));
|
||||||
|
render_diff(&mut terminal, &diff, &mut panels);
|
||||||
|
assert!(screen_text(&terminal).contains("permission denied"));
|
||||||
|
let empty = diff_comparison(vec![]);
|
||||||
|
render_diff(&mut terminal, &empty, &mut panels);
|
||||||
|
assert!(screen_text(&terminal).contains("No changed files"));
|
||||||
|
let binary = diff_comparison(vec![ChangedFile {
|
||||||
|
path: "image.bin".into(),
|
||||||
|
old_path: None,
|
||||||
|
status: FileStatus::Added,
|
||||||
|
before: vec![],
|
||||||
|
after: vec![],
|
||||||
|
hunks: vec![],
|
||||||
|
notice: Some("Binary file; text comparison unavailable".into()),
|
||||||
|
}]);
|
||||||
|
render_diff(&mut terminal, &binary, &mut panels);
|
||||||
|
assert!(screen_text(&terminal).contains("Binary file"));
|
||||||
|
let blank = diff_comparison(vec![ChangedFile {
|
||||||
|
path: "empty.txt".into(),
|
||||||
|
old_path: None,
|
||||||
|
status: FileStatus::Added,
|
||||||
|
before: vec![],
|
||||||
|
after: vec![],
|
||||||
|
hunks: vec![],
|
||||||
|
notice: None,
|
||||||
|
}]);
|
||||||
|
render_diff(&mut terminal, &blank, &mut panels);
|
||||||
|
assert!(screen_text(&terminal).contains("Empty file"));
|
||||||
|
for (width, height) in [(1, 1), (2, 2), (3, 3), (8, 4), (39, 5)] {
|
||||||
|
let mut tiny =
|
||||||
|
Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap();
|
||||||
|
for state in [&diff, &empty, &binary, &blank] {
|
||||||
|
render_diff(&mut tiny, state, &mut panels);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn actual_diff_draw_path_is_modal_and_escape_restores_document_viewport() {
|
||||||
|
let directory = Directory::new();
|
||||||
|
let mut editor = Editor::open(
|
||||||
|
Some(directory.file("source.txt", "original\nsecond")),
|
||||||
|
vec![],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let original = editor.lines().to_vec();
|
||||||
|
let cursor = editor.cursor();
|
||||||
|
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 12)).unwrap();
|
||||||
|
let mut viewports = BufferViewports::default();
|
||||||
|
terminal
|
||||||
|
.draw(|frame| viewports.draw(frame, &editor))
|
||||||
|
.unwrap();
|
||||||
|
editor.execute("diff");
|
||||||
|
let (id, _) = editor.take_diff_request().unwrap();
|
||||||
|
editor.apply_diff_result(id, Ok(crate::diff::Outcome::Catalog(diff_catalog())));
|
||||||
|
terminal
|
||||||
|
.draw(|frame| viewports.draw(frame, &editor))
|
||||||
|
.unwrap();
|
||||||
|
assert!(screen_text(&terminal).contains("Choose Before"));
|
||||||
|
assert!(viewports.document.geometry.is_none());
|
||||||
|
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 {
|
||||||
|
before: "old revision".into(),
|
||||||
|
after: "Working checkout".into(),
|
||||||
|
files: vec![deletion_file()],
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
editor.handle(Input::Paste("must not edit".into()));
|
||||||
|
editor.handle(Input::Char('i'));
|
||||||
|
terminal
|
||||||
|
.draw(|frame| viewports.draw(frame, &editor))
|
||||||
|
.unwrap();
|
||||||
|
assert!(screen_text(&terminal).contains("deleted-lines.txt"));
|
||||||
|
assert!(viewports.document.geometry.is_none());
|
||||||
|
assert_eq!(editor.lines(), original);
|
||||||
|
editor.handle(Input::Escape);
|
||||||
|
terminal
|
||||||
|
.draw(|frame| viewports.draw(frame, &editor))
|
||||||
|
.unwrap();
|
||||||
|
assert!(screen_text(&terminal).contains("original"));
|
||||||
|
assert!(viewports.document.geometry.is_some());
|
||||||
|
assert_eq!(editor.cursor(), cursor);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn diff_wheel_cannot_scroll_the_hidden_document_even_outside_the_picker() {
|
||||||
|
let directory = Directory::new();
|
||||||
|
let path = directory.file("rows.txt", &["original"; 60].join("\n"));
|
||||||
|
let mut session = EditorSession::open(Some(path), vec![], None).unwrap();
|
||||||
|
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 30)).unwrap();
|
||||||
|
let mut viewports = BufferViewports::default();
|
||||||
|
terminal
|
||||||
|
.draw(|frame| viewports.draw(frame, session.editor()))
|
||||||
|
.unwrap();
|
||||||
|
let id = session.editor().buffer_id();
|
||||||
|
let offset = viewports.offsets[&id];
|
||||||
|
let cursor = session.editor().cursor();
|
||||||
|
session.execute("diff");
|
||||||
|
terminal
|
||||||
|
.draw(|frame| viewports.draw(frame, session.editor()))
|
||||||
|
.unwrap();
|
||||||
|
assert!(viewports.scroll(
|
||||||
|
&mut session,
|
||||||
|
document_wheel(MouseEventKind::ScrollDown, 0, 0)
|
||||||
|
));
|
||||||
|
assert!(viewports.scroll(
|
||||||
|
&mut session,
|
||||||
|
document_wheel(MouseEventKind::ScrollDown, 60, 15)
|
||||||
|
));
|
||||||
|
assert_eq!(viewports.offsets[&id], offset);
|
||||||
|
assert_eq!(session.editor().cursor(), cursor);
|
||||||
|
assert!(viewports.document.geometry.is_none());
|
||||||
|
session.handle(Input::Escape);
|
||||||
|
terminal
|
||||||
|
.draw(|frame| viewports.draw(frame, session.editor()))
|
||||||
|
.unwrap();
|
||||||
|
assert!(viewports.document.geometry.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn completion_windows_selected_unicode_without_overwriting_gutter_dock_or_cursor() {
|
fn completion_windows_selected_unicode_without_overwriting_gutter_dock_or_cursor() {
|
||||||
let mut completion = Completion {
|
let mut completion = Completion {
|
||||||
|
|||||||
+154
@@ -0,0 +1,154 @@
|
|||||||
|
use std::{
|
||||||
|
fs,
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
process::Command,
|
||||||
|
sync::atomic::{AtomicU64, Ordering},
|
||||||
|
thread,
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
|
||||||
|
use vamp::{diff::Phase, editor::Input, session::EditorSession};
|
||||||
|
|
||||||
|
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||||||
|
|
||||||
|
struct Fixture(PathBuf);
|
||||||
|
|
||||||
|
impl Fixture {
|
||||||
|
fn new() -> Self {
|
||||||
|
let root = std::env::temp_dir().join(format!(
|
||||||
|
"vamp-diff-session-{}-{}",
|
||||||
|
std::process::id(),
|
||||||
|
NEXT.fetch_add(1, Ordering::Relaxed),
|
||||||
|
));
|
||||||
|
fs::create_dir_all(root.join("repo")).unwrap();
|
||||||
|
Self(root)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn git(&self, args: &[&str]) -> String {
|
||||||
|
let output = Command::new("git")
|
||||||
|
.args(args)
|
||||||
|
.current_dir(self.0.join("repo"))
|
||||||
|
.env("GIT_CONFIG_NOSYSTEM", "1")
|
||||||
|
.env("GIT_CONFIG_GLOBAL", "/dev/null")
|
||||||
|
.env("GIT_AUTHOR_NAME", "Diff Fixture")
|
||||||
|
.env("GIT_AUTHOR_EMAIL", "fixture@example.invalid")
|
||||||
|
.env("GIT_COMMITTER_NAME", "Diff Fixture")
|
||||||
|
.env("GIT_COMMITTER_EMAIL", "fixture@example.invalid")
|
||||||
|
.env("GIT_AUTHOR_DATE", "2000-01-01T00:00:00Z")
|
||||||
|
.env("GIT_COMMITTER_DATE", "2000-01-01T00:00:00Z")
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
output.status.success(),
|
||||||
|
"{}",
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
String::from_utf8(output.stdout).unwrap().trim().to_owned()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Fixture {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = fs::remove_dir_all(&self.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wait_until(session: &mut EditorSession, ready: impl Fn(&EditorSession) -> bool) {
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(10);
|
||||||
|
while !ready(session) {
|
||||||
|
session.poll();
|
||||||
|
assert!(
|
||||||
|
Instant::now() < deadline,
|
||||||
|
"diff state: {:?}",
|
||||||
|
session.editor().diff()
|
||||||
|
);
|
||||||
|
if let Some(diff) = session.editor().diff() {
|
||||||
|
assert!(diff.error.is_none(), "{:?}", diff.error);
|
||||||
|
}
|
||||||
|
thread::sleep(Duration::from_millis(1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn type_query(session: &mut EditorSession, text: &str) {
|
||||||
|
for ch in text.chars() {
|
||||||
|
session.handle(Input::Char(ch));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn typed_diff_compares_disk_not_unsaved_buffers_and_preserves_undo() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
fixture.git(&["init", "-q"]);
|
||||||
|
let source = fixture.0.join("repo/buffer.rs");
|
||||||
|
fs::write(&source, "before\n").unwrap();
|
||||||
|
fixture.git(&["add", "buffer.rs"]);
|
||||||
|
fixture.git(&["commit", "-qm", "base"]);
|
||||||
|
let base = fixture.git(&["rev-parse", "HEAD"]);
|
||||||
|
fs::write(&source, "after\n").unwrap();
|
||||||
|
let config = fixture.0.join("config.rb");
|
||||||
|
fs::write(
|
||||||
|
&config,
|
||||||
|
"Vamp.configure do |config|\n config.clipboard_mode = 'separate'\n config.bind 'leader d', config.core.diff\nend\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let mut session =
|
||||||
|
EditorSession::open(Some(source.clone()), vec!["text".into()], Some(&config)).unwrap();
|
||||||
|
assert!(session.editor().startup_diagnostic().is_none());
|
||||||
|
for input in [Input::Char('i'), Input::Char('u'), Input::Escape] {
|
||||||
|
session.handle(input);
|
||||||
|
}
|
||||||
|
let cursor = session.editor().cursor();
|
||||||
|
session.handle(Input::Char(' '));
|
||||||
|
session.handle(Input::Char('d'));
|
||||||
|
wait_until(&mut session, |session| {
|
||||||
|
session
|
||||||
|
.editor()
|
||||||
|
.diff()
|
||||||
|
.is_some_and(|diff| diff.catalog.is_some() && !diff.loading)
|
||||||
|
});
|
||||||
|
type_query(&mut session, &base);
|
||||||
|
session.handle(Input::Down);
|
||||||
|
session.handle(Input::Enter);
|
||||||
|
assert_eq!(session.editor().diff().unwrap().phase, Phase::After);
|
||||||
|
session.handle(Input::Enter);
|
||||||
|
wait_until(&mut session, |session| {
|
||||||
|
session
|
||||||
|
.editor()
|
||||||
|
.diff()
|
||||||
|
.is_some_and(|diff| diff.phase == Phase::View)
|
||||||
|
});
|
||||||
|
let comparison = session
|
||||||
|
.editor()
|
||||||
|
.diff()
|
||||||
|
.unwrap()
|
||||||
|
.comparison
|
||||||
|
.as_ref()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(comparison.files.len(), 1);
|
||||||
|
let file = &comparison.files[0];
|
||||||
|
assert_eq!(file.path, Path::new("buffer.rs"));
|
||||||
|
assert_eq!(file.before, ["before"]);
|
||||||
|
assert_eq!(file.after, ["after"]);
|
||||||
|
for input in [
|
||||||
|
Input::Char('i'),
|
||||||
|
Input::Char('x'),
|
||||||
|
Input::Paste("overwrite".into()),
|
||||||
|
] {
|
||||||
|
session.handle(input);
|
||||||
|
}
|
||||||
|
session.handle(Input::Escape);
|
||||||
|
assert!(session.editor().diff().is_none());
|
||||||
|
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('u'));
|
||||||
|
assert_eq!(session.editor().context().text, "after\n");
|
||||||
|
session.execute(":diff");
|
||||||
|
assert!(session.editor().diff().is_some());
|
||||||
|
session.handle(Input::Escape);
|
||||||
|
session.poll();
|
||||||
|
assert!(session.editor().diff().is_none());
|
||||||
|
assert_eq!(fixture.git(&["rev-parse", "HEAD"]), base);
|
||||||
|
assert_eq!(fixture.git(&["status", "--porcelain"]), "M buffer.rs");
|
||||||
|
}
|
||||||
+1022
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user