From 81a2bf9a02cab1e4c623d87f637c6bd3f99a5c1f Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Sat, 10 Oct 2026 23:01:34 +0200 Subject: [PATCH] feat: add local and remote repository diff views --- README.md | 1 + docs/architecture.md | 4 +- docs/configuration.md | 3 +- docs/development.md | 3 + docs/editing.md | 20 + examples/config.rb | 1 + src/diff.rs | 1065 +++++++++++++++++++++++++++++++ src/editor.rs | 163 +++++ src/lib.rs | 2 + src/main.rs | 3 + src/repository.rs | 1393 +++++++++++++++++++++++++++++++++++++++++ src/session.rs | 246 +++++++- src/tui.rs | 872 +++++++++++++++++++++++++- tests/diff.rs | 154 +++++ tests/repository.rs | 1022 ++++++++++++++++++++++++++++++ 15 files changed, 4947 insertions(+), 5 deletions(-) create mode 100644 src/diff.rs create mode 100644 src/repository.rs create mode 100644 tests/diff.rs create mode 100644 tests/repository.rs diff --git a/README.md b/README.md index 30b7e51..085ba3d 100644 --- a/README.md +++ b/README.md @@ -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 | | 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 | 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/architecture.md b/docs/architecture.md index f5319b9..d1766ad 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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. - `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. - `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. - `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. - `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. - `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. diff --git a/docs/configuration.md b/docs/configuration.md index ae54e8e..cfb6050 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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 | `edit`, `buffers`, `buffer`, `buffer_next`, `buffer_previous`, `buffer_delete`, `buffer_force_delete` | | Normal | `follow_link` | +| Normal | `diff` | | Normal | `jump_back`, `jump_forward` | | Normal | `lsp.definition`, `lsp.diagnostics`, `lsp.hover` | | 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 -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 config.bind "ctrl+f", config.core.down, mode: config.modes.selection diff --git a/docs/development.md b/docs/development.md index 34eff1c..a6af360 100644 --- a/docs/development.md +++ b/docs/development.md @@ -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. +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 - 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. - 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. +- 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. diff --git a/docs/editing.md b/docs/editing.md index a6d5131..9e53a17 100644 --- a/docs/editing.md +++ b/docs/editing.md @@ -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. +## 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 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. diff --git a/examples/config.rb b/examples/config.rb index 1c883b1..b5a7bce 100644 --- a/examples/config.rb +++ b/examples/config.rb @@ -24,6 +24,7 @@ Vamp.configure do |config| config.bind "g", "greet" config.bind "leader g", "greet" config.bind "leader q", config.core.quit + config.bind "leader v", config.core.diff config.bind "ctrl+r", config.core.redo config.bind "leader r", config.core.repeat_last_edit config.bind "ctrl+c", config.core.normal, mode: config.modes.insert diff --git a/src/diff.rs b/src/diff.rs new file mode 100644 index 0000000..260037e --- /dev/null +++ b/src/diff.rs @@ -0,0 +1,1065 @@ +use crate::editor::Input; +use std::path::PathBuf; +use unicode_segmentation::UnicodeSegmentation; +use unicode_width::UnicodeWidthStr; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum RepoKind { + Jujutsu, + Git, +} +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Category { + Revisions, + Bookmarks, + Commits, + Branches, + Tags, +} +impl Category { + pub fn label(self) -> &'static str { + match self { + Self::Revisions => "Revisions", + Self::Bookmarks => "Bookmarks", + Self::Commits => "Commits", + Self::Branches => "Branches", + Self::Tags => "Tags", + } + } +} +impl RepoKind { + pub fn categories(self) -> &'static [Category] { + match self { + Self::Jujutsu => &[Category::Revisions, Category::Bookmarks], + Self::Git => &[Category::Commits, Category::Branches, Category::Tags], + } + } +} +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum RevisionTarget { + Working, + Commit(String), + Remote { remote: String, reference: String }, + FetchRemote(String), +} +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Revision { + pub target: RevisionTarget, + pub label: String, + pub description: String, + pub category: Category, +} +#[derive(Debug)] +pub struct Catalog { + pub kind: RepoKind, + pub entries: Vec, + pub notice: Option, +} +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum FileStatus { + Added, + Removed, + Modified, + Renamed, +} +#[derive(Debug)] +pub struct Hunk { + pub before_start: usize, + pub before_len: usize, + pub after_start: usize, + pub after_len: usize, +} +#[derive(Debug)] +pub struct ChangedFile { + pub path: PathBuf, + pub old_path: Option, + pub status: FileStatus, + pub before: Vec, + pub after: Vec, + pub hunks: Vec, + pub notice: Option, +} +#[derive(Debug)] +pub struct Comparison { + pub before: String, + pub after: String, + pub files: Vec, +} +#[derive(Debug)] +pub enum Operation { + Discover, + FetchRemote(String), + Compare { before: Revision, after: Revision }, +} +#[derive(Debug)] +pub enum Outcome { + Catalog(Catalog), + Comparison(Comparison), +} +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Phase { + Before, + After, + View, +} +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Focus { + Files, + Before, + After, +} +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum RowKind { + Equal, + Added, + Removed, + Changed, +} +#[derive(Debug)] +pub struct AlignedRow { + pub before: Option, + pub after: Option, + pub kind: RowKind, +} + +#[derive(Debug)] +struct SelectorPosition { + category: usize, + query: String, + selected: RevisionTarget, +} +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum RequestKind { + Catalog, + Comparison, +} + +#[derive(Debug)] +pub struct DiffState { + pub catalog: Option, + pub phase: Phase, + pub before: Option, + pub after: Option, + pub category: usize, + pub query: String, + pub selected: usize, + pub loading: bool, + pub error: Option, + pub comparison: Option, + pub file: usize, + pub focus: Focus, + pub top: usize, + pub left_before: usize, + pub left_after: usize, + pub rows: Vec, + pending: Option, + request_kind: Option, + visible: Vec, + search_labels: Vec, + before_position: Option, + max_before: usize, + max_after: usize, +} + +impl DiffState { + pub(crate) fn new() -> Self { + Self { + catalog: None, + phase: Phase::Before, + before: None, + after: None, + category: 0, + query: String::new(), + selected: 0, + loading: true, + error: None, + comparison: None, + file: 0, + focus: Focus::Files, + top: 0, + left_before: 0, + left_after: 0, + rows: Vec::new(), + pending: Some(Operation::Discover), + request_kind: Some(RequestKind::Catalog), + visible: Vec::new(), + search_labels: Vec::new(), + before_position: None, + max_before: 0, + max_after: 0, + } + } + + pub fn entries(&self) -> impl ExactSizeIterator { + self.visible + .iter() + .map(|&index| &self.catalog.as_ref().expect("visible catalog").entries[index]) + } + + pub(crate) fn take_request(&mut self) -> Option { + self.pending.take() + } + + fn queue(&mut self, operation: Operation) { + self.request_kind = Some(match operation { + Operation::Compare { .. } => RequestKind::Comparison, + _ => RequestKind::Catalog, + }); + self.pending = Some(operation); + self.loading = true; + self.error = None; + } + + pub(crate) fn apply_result(&mut self, result: Result) { + let expected = self.request_kind.take(); + self.loading = false; + self.pending = None; + match result { + Ok(Outcome::Catalog(catalog)) if expected == Some(RequestKind::Catalog) => { + let selected = self + .entries() + .nth(self.selected) + .map(|entry| entry.target.clone()); + self.search_labels = catalog + .entries + .iter() + .map(|entry| { + let id = match &entry.target { + RevisionTarget::Commit(id) => id.as_str(), + _ => "", + }; + format!("{} {} {id}", entry.label, entry.description).to_lowercase() + }) + .collect(); + self.category = self.category.min(catalog.kind.categories().len() - 1); + self.catalog = Some(catalog); + self.refilter(); + if let Some(target) = selected { + self.select_target(&target); + } + self.error = None; + } + Ok(Outcome::Comparison(comparison)) if expected == Some(RequestKind::Comparison) => { + self.comparison = Some(comparison); + self.phase = Phase::View; + self.focus = Focus::Files; + self.file = 0; + self.align_file(); + self.error = None; + } + Ok(_) => self.error = Some("Unexpected repository response".into()), + Err(error) => self.error = Some(error), + } + } + + fn refilter(&mut self) { + self.visible.clear(); + if let Some(catalog) = &self.catalog { + let category = catalog.kind.categories()[self.category]; + let query = self.query.to_lowercase(); + let tokens: Vec<_> = query.split_whitespace().collect(); + // Working is always pinned, even outside its category or query. + self.visible.extend( + catalog + .entries + .iter() + .enumerate() + .filter(|(_, entry)| entry.target == RevisionTarget::Working) + .map(|(index, _)| index), + ); + self.visible.extend( + catalog + .entries + .iter() + .enumerate() + .filter(|(index, entry)| { + entry.target != RevisionTarget::Working + && entry.category == category + && tokens + .iter() + .all(|token| self.search_labels[*index].contains(token)) + }) + .map(|(index, _)| index), + ); + } + self.selected = 0; + } + + fn select_target(&mut self, target: &RevisionTarget) { + let selected = self.entries().position(|entry| &entry.target == target); + if let Some(index) = selected { + self.selected = index; + } + } + + pub(crate) fn navigate(&mut self, forward: bool) { + if self.phase == Phase::View { + if self.focus == Focus::Files { + let count = self + .comparison + .as_ref() + .map_or(0, |comparison| comparison.files.len()); + let file = if forward { + self.file.saturating_add(1).min(count.saturating_sub(1)) + } else { + self.file.saturating_sub(1) + }; + if file != self.file { + self.file = file; + self.align_file(); + } + } else { + self.top = if forward { + self.top + .saturating_add(1) + .min(self.rows.len().saturating_sub(1)) + } else { + self.top.saturating_sub(1) + }; + } + } else { + let count = self.visible.len(); + self.selected = if count == 0 { + 0 + } else if forward { + (self.selected + 1) % count + } else if self.selected == 0 { + count - 1 + } else { + self.selected - 1 + }; + } + } + + /// Returns true only when the modal should close. All other inputs are consumed. + pub(crate) fn handle(&mut self, input: Input) -> bool { + if input == Input::Escape { + if self.phase != Phase::After { + return true; + } + self.pending = None; + self.request_kind = None; + self.loading = false; + 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; + } + if self.phase == Phase::View { + self.handle_view(input); + return false; + } + match input { + Input::Char('\t') | Input::BackTab => { + if let Some(catalog) = &self.catalog { + let count = catalog.kind.categories().len(); + self.category = if input == Input::BackTab { + (self.category + count - 1) % count + } else { + (self.category + 1) % count + }; + self.refilter(); + } + } + Input::Up | Input::Ctrl('k') => self.navigate(false), + Input::Down | Input::Ctrl('j') => self.navigate(true), + Input::Home => self.selected = 0, + Input::End => self.selected = self.visible.len().saturating_sub(1), + Input::Enter if !self.loading => self.choose(), + Input::Backspace => { + if let Some((byte, _)) = self.query.grapheme_indices(true).next_back() { + self.query.truncate(byte); + self.refilter(); + } + } + Input::Ctrl('u') => { + self.query.clear(); + self.refilter(); + } + Input::Char(ch) if !ch.is_control() => { + self.query.push(ch); + self.refilter(); + } + _ => {} + } + false + } + + fn choose(&mut self) { + let revision = self.entries().nth(self.selected).cloned(); + let Some(revision) = revision else { + // Discovery can be retried without closing the modal. + if self.catalog.is_none() { + self.queue(Operation::Discover); + } + return; + }; + if let RevisionTarget::FetchRemote(remote) = &revision.target { + self.queue(Operation::FetchRemote(remote.clone())); + } else if self.phase == Phase::Before { + self.before_position = Some(SelectorPosition { + category: self.category, + query: self.query.clone(), + selected: revision.target.clone(), + }); + self.before = Some(revision); + self.after = None; + self.phase = Phase::After; + self.query.clear(); + self.refilter(); + self.error = None; + } else if let Some(before) = &self.before { + self.after = Some(revision.clone()); + self.queue(Operation::Compare { + before: before.clone(), + after: revision, + }); + } + } + + fn handle_view(&mut self, input: Input) { + match input { + Input::Char('\t') => { + self.focus = match self.focus { + Focus::Files => Focus::Before, + Focus::Before => Focus::After, + Focus::After => Focus::Files, + } + } + Input::BackTab => { + self.focus = match self.focus { + Focus::Files => Focus::After, + Focus::After => Focus::Before, + Focus::Before => Focus::Files, + } + } + Input::Up | Input::Ctrl('k') => self.navigate(false), + Input::Down | Input::Ctrl('j') => self.navigate(true), + Input::Right | Input::Ctrl('l') if self.focus == Focus::Files => { + self.focus = Focus::Before + } + Input::Left | Input::Ctrl('h') if self.focus == Focus::Files => { + self.focus = Focus::After + } + Input::Left | Input::Ctrl('h') => match self.focus { + Focus::Before => self.left_before = self.left_before.saturating_sub(1), + Focus::After => self.left_after = self.left_after.saturating_sub(1), + Focus::Files => {} + }, + Input::Right | Input::Ctrl('l') => match self.focus { + Focus::Before => { + self.left_before = self.left_before.saturating_add(1).min(self.max_before) + } + Focus::After => { + self.left_after = self.left_after.saturating_add(1).min(self.max_after) + } + Focus::Files => {} + }, + Input::Home | Input::End => { + if self.focus == Focus::Files { + self.file = if input == Input::Home { + 0 + } else { + self.comparison + .as_ref() + .map_or(0, |comparison| comparison.files.len().saturating_sub(1)) + }; + self.align_file(); + } else { + self.top = if input == Input::Home { + 0 + } else { + self.rows.len().saturating_sub(1) + }; + } + } + _ => {} + } + } + + fn align_file(&mut self) { + self.rows.clear(); + self.top = 0; + self.left_before = 0; + self.left_after = 0; + self.max_before = 0; + self.max_after = 0; + let Some(file) = self + .comparison + .as_ref() + .and_then(|comparison| comparison.files.get(self.file)) + else { + return; + }; + let width = |lines: &[String]| { + lines + .iter() + .map(|line| { + line.graphemes(true).fold(0, |column, grapheme| { + column + + if grapheme == "\t" { + 4 - column % 4 + } else { + UnicodeWidthStr::width(grapheme) + } + }) + }) + .max() + .unwrap_or(0) + }; + self.max_before = width(&file.before); + self.max_after = width(&file.after); + 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); + append_rows( + &mut self.rows, + before, + start_before, + after, + start_after, + false, + ); + before = start_before; + after = start_after; + 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()); + append_rows(&mut self.rows, before, end_before, after, end_after, true); + before = end_before; + after = end_after; + } + append_rows( + &mut self.rows, + before, + file.before.len(), + after, + file.after.len(), + false, + ); + } +} + +fn append_rows( + rows: &mut Vec, + before: usize, + before_end: usize, + after: usize, + after_end: usize, + changed: bool, +) { + for offset in 0..(before_end - before).max(after_end - after) { + let before = (offset < before_end - before).then_some(before + offset); + let after = (offset < after_end - after).then_some(after + offset); + let kind = match (before, after) { + (Some(_), Some(_)) if changed => RowKind::Changed, + (Some(_), Some(_)) => RowKind::Equal, + (Some(_), None) => RowKind::Removed, + (None, Some(_)) => RowKind::Added, + (None, None) => unreachable!("alignment has at least one line"), + }; + rows.push(AlignedRow { + before, + after, + kind, + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::editor::{Editor, Effect, Mode}; + + fn revision( + target: RevisionTarget, + label: &str, + description: &str, + category: Category, + ) -> Revision { + Revision { + target, + label: label.into(), + description: description.into(), + category, + } + } + + fn catalog() -> Catalog { + Catalog { + kind: RepoKind::Git, + entries: vec![ + revision( + RevisionTarget::Working, + "Working checkout", + "", + Category::Commits, + ), + revision( + RevisionTarget::Commit("abcdef123".into()), + "abcdef123", + "Fix 界 e\u{301}", + Category::Commits, + ), + revision( + RevisionTarget::Commit("fedcba321".into()), + "fedcba321", + "Another change", + Category::Commits, + ), + revision( + RevisionTarget::FetchRemote("origin".into()), + "Load history from origin", + "", + Category::Commits, + ), + revision( + RevisionTarget::Commit("branch-tip".into()), + "topic/branch", + "", + Category::Branches, + ), + revision( + RevisionTarget::Remote { + remote: "origin".into(), + reference: "refs/heads/topic".into(), + }, + "topic@origin", + "", + Category::Branches, + ), + revision( + RevisionTarget::Commit("tag-tip".into()), + "release", + "", + Category::Tags, + ), + ], + notice: None, + } + } + + fn editor() -> Editor { + Editor::open(None, vec![]).unwrap() + } + + fn open(editor: &mut Editor) { + assert!(editor.execute("diff").is_none()); + let (id, operation) = editor.take_diff_request().unwrap(); + assert!(matches!(operation, Operation::Discover)); + assert!(editor.apply_diff_result(id, Ok(Outcome::Catalog(catalog())))); + } + + fn text(editor: &mut Editor, text: &str) { + for ch in text.chars() { + editor.handle(Input::Char(ch)); + } + } + + fn choose_pair(editor: &mut Editor) -> u64 { + editor.handle(Input::Down); + editor.handle(Input::Enter); + assert_eq!(editor.diff().unwrap().phase, Phase::After); + editor.handle(Input::Enter); + let (id, operation) = editor.take_diff_request().unwrap(); + match operation { + Operation::Compare { before, after } => { + assert_eq!(before.target, RevisionTarget::Commit("abcdef123".into())); + assert_eq!(after.target, RevisionTarget::Working); + } + _ => panic!("expected compare"), + } + id + } + + fn comparison() -> Comparison { + Comparison { + before: "abcdef123".into(), + after: "Working checkout".into(), + files: vec![ + ChangedFile { + path: "renamed.txt".into(), + old_path: Some("old.txt".into()), + status: FileStatus::Renamed, + before: vec![ + "same".into(), + "old".into(), + "tail".into(), + "deleted".into(), + "end".into(), + ], + after: vec![ + "same".into(), + "new".into(), + "extra".into(), + "tail".into(), + "end".into(), + ], + hunks: vec![ + Hunk { + before_start: 1, + before_len: 1, + after_start: 1, + after_len: 2, + }, + Hunk { + before_start: 3, + before_len: 1, + after_start: 4, + after_len: 0, + }, + ], + notice: None, + }, + ChangedFile { + path: "added.txt".into(), + old_path: None, + status: FileStatus::Added, + before: vec![], + after: vec!["a".into(), "b".into()], + hunks: vec![Hunk { + before_start: 0, + before_len: 0, + after_start: 0, + after_len: 2, + }], + notice: None, + }, + ChangedFile { + path: "removed.bin".into(), + old_path: None, + status: FileStatus::Removed, + before: vec![], + after: vec![], + hunks: vec![], + notice: Some("Binary file".into()), + }, + ], + } + } + + #[test] + fn selector_filters_literal_unicode_tokens_and_pins_working_in_each_category() { + let mut editor = editor(); + open(&mut editor); + text(&mut editor, "FIX 界 e\u{301}"); + assert_eq!( + editor + .diff() + .unwrap() + .entries() + .map(|entry| entry.label.as_str()) + .collect::>(), + ["Working checkout", "abcdef123"] + ); + editor.handle(Input::Backspace); + assert_eq!(editor.diff().unwrap().query, "FIX 界 "); + editor.handle(Input::Char('\t')); + assert_eq!(editor.diff().unwrap().category, 1); + assert_eq!(editor.diff().unwrap().entries().len(), 1); + editor.handle(Input::Ctrl('u')); + assert_eq!(editor.diff().unwrap().entries().len(), 3); + editor.handle(Input::BackTab); + assert_eq!(editor.diff().unwrap().category, 0); + editor.handle(Input::BackTab); + assert_eq!(editor.diff().unwrap().category, 2); + assert_eq!(editor.diff().unwrap().entries().len(), 2); + editor.handle(Input::Char('\t')); + assert_eq!(editor.diff().unwrap().category, 0); + editor + .bind("selection", "x", Some("core.down".into())) + .unwrap(); + editor.handle(Input::Char('x')); + assert_eq!(editor.diff().unwrap().selected, 1); + assert!(editor.diff().unwrap().query.is_empty()); + editor.bind("selection", "ctrl+j", None).unwrap(); + editor.handle(Input::Ctrl('j')); + assert_eq!(editor.diff().unwrap().selected, 1); + } + + #[test] + fn loader_preserves_after_step_query_category_and_selection_then_compares() { + let mut editor = editor(); + open(&mut editor); + editor.handle(Input::Down); + editor.handle(Input::Enter); + text(&mut editor, "origin"); + editor.handle(Input::Down); + editor.handle(Input::Enter); + let (id, operation) = editor.take_diff_request().unwrap(); + assert!(matches!(operation, Operation::FetchRemote(remote) if remote == "origin")); + assert_eq!(editor.diff().unwrap().phase, Phase::After); + assert!(editor.diff().unwrap().loading); + editor.handle(Input::Enter); + assert!(editor.take_diff_request().is_none()); + let mut loaded = catalog(); + loaded.entries.push(revision( + RevisionTarget::Commit("remote-commit".into()), + "remote-commit", + "origin remote history", + Category::Commits, + )); + assert!(editor.apply_diff_result(id, Ok(Outcome::Catalog(loaded)))); + let state = editor.diff().unwrap(); + assert_eq!(state.phase, Phase::After); + assert_eq!(state.query, "origin"); + assert_eq!(state.category, 0); + assert_eq!(state.selected, 1); + assert_eq!(state.entries().len(), 3); + editor.handle(Input::Down); + editor.handle(Input::Enter); + let (id, operation) = editor.take_diff_request().unwrap(); + assert!(matches!(operation, Operation::Compare { after, .. } + if after.target == RevisionTarget::Commit("remote-commit".into()))); + assert!(editor.apply_diff_result(id, Err("comparison failed".into()))); + assert_eq!(editor.diff().unwrap().phase, Phase::After); + assert_eq!(editor.diff().unwrap().query, "origin"); + assert!(editor.diff().unwrap().error.is_some()); + editor.handle(Input::Enter); + assert!(matches!( + editor.take_diff_request(), + Some((_, Operation::Compare { .. })) + )); + } + + #[test] + fn after_escape_restores_before_query_and_selected_revision_and_cancels_request() { + let mut editor = editor(); + open(&mut editor); + editor.handle(Input::Char('\t')); + text(&mut editor, "topic"); + editor.handle(Input::Down); + editor.handle(Input::Enter); + assert!(editor.diff().unwrap().query.is_empty()); + editor.handle(Input::Enter); + let (id, _) = editor.take_diff_request().unwrap(); + editor.handle(Input::Escape); + let state = editor.diff().unwrap(); + assert_eq!(state.phase, Phase::Before); + assert_eq!(state.category, 1); + assert_eq!(state.query, "topic"); + assert_eq!(state.selected, 1); + assert!(!state.loading); + assert!(!editor.apply_diff_result(id, Ok(Outcome::Comparison(comparison())))); + editor.handle(Input::Enter); + assert_eq!(editor.diff().unwrap().phase, Phase::After); + editor.handle(Input::Escape); + editor.handle(Input::Escape); + assert!(editor.diff().is_none()); + } + + #[test] + fn aligned_hunks_navigation_shared_top_independent_horizontal_and_file_reset() { + let mut editor = editor(); + open(&mut editor); + let id = choose_pair(&mut editor); + assert!(editor.apply_diff_result(id, Ok(Outcome::Comparison(comparison())))); + assert_eq!( + editor + .diff() + .unwrap() + .rows + .iter() + .map(|row| (row.before, row.after, row.kind)) + .collect::>(), + [ + (Some(0), Some(0), RowKind::Equal), + (Some(1), Some(1), RowKind::Changed), + (None, Some(2), RowKind::Added), + (Some(2), Some(3), RowKind::Equal), + (Some(3), None, RowKind::Removed), + (Some(4), Some(4), RowKind::Equal), + ] + ); + editor.handle(Input::Char('\t')); + assert_eq!(editor.diff().unwrap().focus, Focus::Before); + editor.handle(Input::Down); + editor.handle(Input::Right); + assert_eq!(editor.diff().unwrap().top, 1); + assert_eq!(editor.diff().unwrap().left_before, 1); + editor.handle(Input::Char('\t')); + assert_eq!(editor.diff().unwrap().focus, Focus::After); + editor.handle(Input::Ctrl('j')); + editor.handle(Input::Ctrl('l')); + assert_eq!(editor.diff().unwrap().top, 2); + assert_eq!(editor.diff().unwrap().left_after, 1); + assert_eq!(editor.diff().unwrap().left_before, 1); + editor.handle(Input::End); + assert_eq!(editor.diff().unwrap().top, 5); + editor.handle(Input::BackTab); + assert_eq!(editor.diff().unwrap().focus, Focus::Before); + editor.handle(Input::BackTab); + editor.handle(Input::Down); + let state = editor.diff().unwrap(); + assert_eq!(state.file, 1); + assert_eq!((state.top, state.left_before, state.left_after), (0, 0, 0)); + assert!( + state + .rows + .iter() + .all(|row| row.before.is_none() && row.kind == RowKind::Added) + ); + editor.handle(Input::End); + assert_eq!(editor.diff().unwrap().file, 2); + assert!(editor.diff().unwrap().rows.is_empty()); + assert!( + editor.diff().unwrap().comparison.as_ref().unwrap().files[2] + .notice + .is_some() + ); + editor.handle(Input::Home); + assert_eq!(editor.diff().unwrap().file, 0); + } + + #[test] + fn discovery_and_fetch_errors_are_retryable_and_stale_responses_cannot_reopen() { + let mut editor = editor(); + editor.execute("diff"); + let (old, _) = editor.take_diff_request().unwrap(); + editor.handle(Input::Escape); + assert!(!editor.apply_diff_result(old, Ok(Outcome::Catalog(catalog())))); + editor.execute("diff"); + let (id, _) = editor.take_diff_request().unwrap(); + assert!(id > old); + assert!(!editor.apply_diff_result(old, Err("stale".into()))); + assert!(editor.diff().unwrap().loading); + assert!(editor.apply_diff_result(id, Err("discovery failed".into()))); + assert!(editor.diff().unwrap().error.is_some()); + editor.handle(Input::Enter); + let (retry, operation) = editor.take_diff_request().unwrap(); + assert!(matches!(operation, Operation::Discover)); + assert!(retry > id); + assert!(editor.apply_diff_result(retry, Ok(Outcome::Catalog(catalog())))); + text(&mut editor, "origin"); + editor.handle(Input::Down); + editor.handle(Input::Enter); + let (fetch, _) = editor.take_diff_request().unwrap(); + assert!(editor.apply_diff_result(fetch, Err("fetch failed".into()))); + assert_eq!(editor.diff().unwrap().entries().len(), 2); + assert_eq!(editor.diff().unwrap().query, "origin"); + editor.handle(Input::Ctrl('u')); + editor.handle(Input::Home); + editor.handle(Input::Enter); + assert_eq!(editor.diff().unwrap().phase, Phase::After); + } + + #[test] + fn native_and_ex_diff_preserve_dirty_buffers_carets_and_undo_and_gate_all_edits() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("original\\nsecond".into())]) + .unwrap(); + editor.handle(Input::Char('i')); + text(&mut editor, "unsaved"); + editor.handle(Input::Escape); + editor.handle(Input::Right); + let original = editor.context().text; + let carets = editor.carets(); + let revision = editor.text_revision(); + let buffers = editor + .buffers() + .map(|buffer| (buffer.id, buffer.dirty)) + .collect::>(); + editor + .bind("normal", "x", Some("core.diff".into())) + .unwrap(); + assert!(editor.handle(Input::Char('x')).is_none()); + let (id, _) = editor.take_diff_request().unwrap(); + assert!(editor.apply_diff_result(id, Ok(Outcome::Catalog(catalog())))); + for input in [ + Input::Char('i'), + Input::Char('d'), + Input::Delete, + Input::Paste("bad\\n:q!".into()), + ] { + assert!(editor.handle(input).is_none()); + } + assert!(editor.execute("bdelete!").is_none()); + assert!(editor.apply(vec![Effect::Replace("bad".into())]).is_err()); + assert_eq!(editor.context().text, original); + assert_eq!(editor.carets(), carets); + assert_eq!(editor.text_revision(), revision); + assert_eq!( + editor + .buffers() + .map(|buffer| (buffer.id, buffer.dirty)) + .collect::>(), + buffers + ); + editor.handle(Input::Escape); + assert_eq!(editor.mode(), Mode::Normal); + open(&mut editor); + let id = choose_pair(&mut editor); + editor.apply_diff_result(id, Ok(Outcome::Comparison(comparison()))); + for input in [ + Input::Char('i'), + Input::Char('u'), + Input::Char('d'), + Input::Paste("bad".into()), + Input::Delete, + Input::Backspace, + Input::Enter, + ] { + editor.handle(input); + } + assert_eq!(editor.context().text, original); + assert_eq!(editor.carets(), carets); + editor.handle(Input::Escape); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, "original\\nsecond"); + } + + #[test] + fn jujutsu_categories_empty_comparison_and_command_mode_entry_are_complete() { + let mut editor = editor(); + editor.handle(Input::Char(':')); + text(&mut editor, "diff"); + editor.handle(Input::Enter); + assert_eq!(editor.mode(), Mode::Normal); + let (id, _) = editor.take_diff_request().unwrap(); + let mut catalog = catalog(); + catalog.kind = RepoKind::Jujutsu; + catalog.entries[0].category = Category::Revisions; + catalog.entries[1].category = Category::Revisions; + catalog.entries[4].category = Category::Bookmarks; + assert!(editor.apply_diff_result(id, Ok(Outcome::Catalog(catalog)))); + assert_eq!(editor.diff().unwrap().entries().len(), 2); + editor.handle(Input::Char('\t')); + assert_eq!( + editor.diff().unwrap().entries().nth(1).unwrap().category, + Category::Bookmarks + ); + editor.handle(Input::Home); + editor.handle(Input::Enter); + editor.handle(Input::Enter); + let (id, _) = editor.take_diff_request().unwrap(); + assert!(editor.apply_diff_result( + id, + Ok(Outcome::Comparison(Comparison { + before: "Working".into(), + after: "Working".into(), + files: vec![], + })) + )); + assert_eq!(editor.diff().unwrap().phase, Phase::View); + assert!(editor.diff().unwrap().rows.is_empty()); + for input in [Input::Down, Input::End, Input::Up, Input::Right, Input::End] { + editor.handle(input); + } + assert_eq!(editor.diff().unwrap().top, 0); + assert_eq!(editor.diff().unwrap().file, 0); + editor.handle(Input::Escape); + assert!(editor.diff().is_none()); + } +} diff --git a/src/editor.rs b/src/editor.rs index 2f78023..7648144 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -1,4 +1,5 @@ use crate::clipboard::{ClipboardMode, SystemClipboard}; +use crate::diff::{DiffState, Operation, Outcome, Phase}; use crate::files::{DockSide, FilePicker, FilePresentation, FileView}; use crate::plugin::{CompletionItem, Diagnostic, SemanticToken, SnippetTabstop, TextPosition}; use serde::Serialize; @@ -553,6 +554,7 @@ core_actions![ "lsp.diagnostics", ), ("core.save", Action::Save, &[Mode::Normal], "w"), + ("core.diff", Action::Diff, &[Mode::Normal], "diff"), ("core.quit", Action::Quit, &[Mode::Normal], "q"), ( "core.force_quit", @@ -1061,6 +1063,9 @@ pub struct Editor { file_picker_view: FileView, file_explorer_view: FileView, count: Option, + diff: Option, + next_diff_request: u64, + outstanding_diff_request: Option, } impl Editor { @@ -1128,6 +1133,9 @@ impl Editor { file_picker_view: FileView::Tree, file_explorer_view: FileView::Tree, count: None, + diff: None, + next_diff_request: 0, + outstanding_diff_request: None, }; editor.refresh_selection_navigation_hint(); editor @@ -1228,6 +1236,77 @@ impl Editor { &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) -> 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> { if id == self.active.id { Some(&self.active) @@ -2857,6 +2936,10 @@ impl Editor { Input::Ctrl(ch) => control_key(ch), input => input, }; + if self.diff.is_some() { + self.handle_diff(input); + return None; + } if self.completion.is_some() { let request = matches!( self.bindings @@ -3140,6 +3223,7 @@ impl Editor { | Action::BufferPrevious | Action::BufferDelete | Action::BufferForceDelete + | Action::Diff | Action::LspDefinition | Action::LspHover | Action::LspDiagnostics => unreachable!("catalog Ex actions dispatch above"), @@ -3653,6 +3737,9 @@ impl Editor { } pub fn execute(&mut self, command: &str) -> Option { + if self.diff.is_some() { + return None; + } let command = command.trim().strip_prefix(':').unwrap_or(command.trim()); let (name, args) = command .split_once(char::is_whitespace) @@ -3694,6 +3781,14 @@ impl Editor { } fn execute_action(&mut self, entry: &CoreAction, args: &str) -> Option { + 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(); let action = entry.action; 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. /// Rejected responses and message-only effects retain the complete set. pub fn apply(&mut self, effects: Vec) -> 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; for effect in &effects { 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::>(); + 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::>(), + 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] fn line_actions_counted_delete_restores_caret_and_pastes_whole_rows() { let mut editor = editor(); diff --git a/src/lib.rs b/src/lib.rs index d3f5614..d0d4c5e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,10 +1,12 @@ pub mod clipboard; +pub mod diff; pub mod editor; pub mod files; pub mod lsp; mod lsp_config; mod markdown; pub mod plugin; +pub mod repository; pub mod ruby; pub mod session; mod snippet; diff --git a/src/main.rs b/src/main.rs index 6f41476..6592d21 100644 --- a/src/main.rs +++ b/src/main.rs @@ -40,6 +40,9 @@ 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 + 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 Set VAMP_RUBY to choose the Ruby executable. diff --git a/src/repository.rs b/src/repository.rs new file mode 100644 index 0000000..e111633 --- /dev/null +++ b/src/repository.rs @@ -0,0 +1,1393 @@ +//! Read-only active-repository discovery and isolated revision comparison. +use crate::diff::{ + Catalog, Category, ChangedFile, Comparison, FileStatus, Hunk, RepoKind, Revision, + RevisionTarget, +}; +use std::{ + collections::{BTreeMap, BTreeSet}, + ffi::{OsStr, OsString}, + fs, + io::{Read, Write}, + path::{Component, Path, PathBuf}, + process::{Command, Stdio}, + sync::{ + Arc, + atomic::{AtomicBool, AtomicU64, Ordering}, + mpsc, + }, + thread, + time::{Duration, Instant}, +}; + +const OUTPUT_LIMIT: usize = 64 * 1024 * 1024; +const FILE_LIMIT: usize = 16 * 1024 * 1024; +const TIMEOUT: Duration = Duration::from_secs(60); +static NEXT_TEMP: AtomicU64 = AtomicU64::new(0); + +struct Temporary(PathBuf); +impl Temporary { + fn new() -> Result { + for _ in 0..100 { + let path = std::env::temp_dir().join(format!( + "vamp-diff-{}-{}", + std::process::id(), + NEXT_TEMP.fetch_add(1, Ordering::Relaxed) + )); + let mut builder = fs::DirBuilder::new(); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + builder.mode(0o700); + } + match builder.create(&path) { + Ok(()) => return Ok(Self(path)), + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(e) => return Err(e.to_string()), + } + } + Err("Cannot allocate private comparison directory".into()) + } +} +impl Drop for Temporary { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +struct ProcessOutput { + code: Option, + stdout: Vec, + stderr: Vec, +} +fn drain(mut pipe: impl Read) -> Result, String> { + let mut bytes = Vec::new(); + let mut chunk = [0; 8192]; + let mut overflow = false; + loop { + match pipe.read(&mut chunk) { + Ok(0) => break, + Ok(n) => { + if bytes.len() + n <= OUTPUT_LIMIT && !overflow { + bytes.extend_from_slice(&chunk[..n]); + } else { + overflow = true; + } + } + Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue, + Err(e) => return Err(e.to_string()), + } + } + if overflow { + Err(format!( + "Subprocess output exceeds {} MiB", + OUTPUT_LIMIT / 1024 / 1024 + )) + } else { + Ok(bytes) + } +} +fn terminate(child: &mut std::process::Child) { + #[cfg(unix)] + unsafe { + unsafe extern "C" { + fn kill(pid: i32, signal: i32) -> i32; + } + kill(-(child.id() as i32), 9); + } + let _ = child.kill(); + let _ = child.wait(); +} +fn run( + mut command: Command, + input: Vec, + cancelled: &AtomicBool, +) -> Result { + if cancelled.load(Ordering::Acquire) { + return Err("Diff operation cancelled".into()); + } + // An inherited object/index override must never redirect a private fetch. + for key in [ + "GIT_DIR", + "GIT_COMMON_DIR", + "GIT_WORK_TREE", + "GIT_INDEX_FILE", + "GIT_OBJECT_DIRECTORY", + "GIT_ALTERNATE_OBJECT_DIRECTORIES", + ] { + command.env_remove(key); + } + command.env("GIT_NO_REPLACE_OBJECTS", "1"); + command + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + command.process_group(0); + } + #[cfg(not(unix))] + return Err( + "Repository comparisons require Unix process-group cancellation on this platform".into(), + ); + #[cfg(unix)] + { + let mut child = command + .spawn() + .map_err(|e| format!("Cannot start repository command: {e}"))?; + let stdout = child.stdout.take().unwrap(); + let stderr = child.stderr.take().unwrap(); + let mut stdin = child.stdin.take().unwrap(); + let (send, receive) = mpsc::channel(); + let out = send.clone(); + let err = send.clone(); + let readers = [ + thread::spawn(move || { + let _ = out.send((0, drain(stdout))); + }), + thread::spawn(move || { + let _ = err.send((1, drain(stderr))); + }), + thread::spawn(move || { + let result = stdin + .write_all(&input) + .map(|_| Vec::new()) + .map_err(|e| e.to_string()); + drop(stdin); + let _ = send.send((2, result)); + }), + ]; + let started = Instant::now(); + let mut results: [Option, String>>; 3] = [None, None, None]; + let mut status = None; + let failure = loop { + while let Ok((i, result)) = receive.try_recv() { + results[i] = Some(result); + } + if cancelled.load(Ordering::Acquire) { + break Some("Diff operation cancelled".to_string()); + } + if started.elapsed() >= TIMEOUT { + break Some("Repository command timed out after 60 seconds".to_string()); + } + if status.is_none() { + match child.try_wait() { + Ok(value) => status = value, + Err(e) => break Some(e.to_string()), + } + } + if status.is_some() && results.iter().all(Option::is_some) { + break None; + } + thread::sleep(Duration::from_millis(10)); + }; + if failure.is_some() { + terminate(&mut child); + } + for reader in readers { + let _ = reader.join(); + } + if let Some(error) = failure { + return Err(error); + } + let stdout = results[0].take().unwrap()?; + let stderr = results[1].take().unwrap()?; + results[2].take().unwrap()?; + Ok(ProcessOutput { + code: status.unwrap().code(), + stdout, + stderr, + }) + } +} +fn checked(output: ProcessOutput) -> Result, String> { + if output.code == Some(0) { + Ok(output.stdout) + } else { + Err(format!( + "Repository command failed ({}): {}", + output.code.map_or("signal".into(), |n| n.to_string()), + String::from_utf8_lossy(&output.stderr).trim() + )) + } +} +fn text(bytes: Vec) -> Result { + String::from_utf8(bytes).map_err(|_| "Repository command returned non-UTF-8 metadata".into()) +} +fn git_command(store: &Path) -> Command { + let mut cmd = Command::new("git"); + cmd.args([ + "--no-pager", + "-c", + "core.hooksPath=/dev/null", + "-c", + "core.fsmonitor=false", + ]) + .arg("--git-dir") + .arg(store) + .env("GIT_OPTIONAL_LOCKS", "0") + .env("GIT_TERMINAL_PROMPT", "0"); + cmd +} +fn jj_command(root: &Path) -> Command { + let mut cmd = Command::new("jj"); + cmd.arg("--repository") + .arg(root) + .args([ + "--ignore-working-copy", + "--at-operation=@", + "--no-pager", + "--color=never", + ]) + .current_dir(root); + cmd +} +fn path_bytes(bytes: &[u8]) -> Result { + #[cfg(unix)] + { + use std::os::unix::ffi::OsStringExt; + Ok(PathBuf::from(OsString::from_vec(bytes.to_vec()))) + } + #[cfg(not(unix))] + { + Ok(PathBuf::from( + std::str::from_utf8(bytes).map_err(|_| "Non-UTF-8 repository path")?, + )) + } +} +fn safe_path(path: &Path) -> Result<(), String> { + if path.as_os_str().is_empty() + || path + .components() + .any(|c| !matches!(c, Component::Normal(_))) + { + return Err(format!("Unsafe repository path: {}", path.display())); + } + if path + .components() + .any(|c| c.as_os_str() == ".git" || c.as_os_str() == ".jj") + { + return Err(format!( + "Repository metadata cannot be compared: {}", + path.display() + )); + } + Ok(()) +} +fn store_for_jj(root: &Path) -> Result { + let mut repo = root.join(".jj/repo"); + if repo.is_file() { + let pointer = fs::read(&repo).map_err(|e| e.to_string())?; + repo = root + .join(".jj") + .join(path_bytes(pointer.strip_suffix(b"\n").unwrap_or(&pointer))?); + } + let store = repo.join("store"); + let target = fs::read(store.join("git_target")) + .map_err(|e| format!("Only Git-backed Jujutsu stores are supported: {e}"))?; + fs::canonicalize(store.join(path_bytes(target.strip_suffix(b"\n").unwrap_or(&target))?)) + .map_err(|e| e.to_string()) +} + +struct Remote { + name: String, + url: String, +} +#[derive(PartialEq, Eq, PartialOrd, Ord)] +struct FileData { + mode: String, + bytes: Vec, + notice: Option, +} +type Snapshot = BTreeMap; + +pub struct Repository { + root: PathBuf, + kind: RepoKind, + store: PathBuf, + temporary: Temporary, + private: Option<(PathBuf, PathBuf)>, // workspace and Git object store + remotes: Vec, + remote_refs: BTreeMap<(String, String), String>, + loaded: BTreeSet, + registered: BTreeSet, + cancelled: Arc, +} +impl Repository { + pub fn open(root: &Path, cancelled: Arc) -> Result { + let root = fs::canonicalize(root).map_err(|e| e.to_string())?; + let discovered = root + .ancestors() + .find(|p| p.join(".jj").exists() || p.join(".git").exists()) + .ok_or("No Jujutsu or Git working-tree repository found")?; + let (root, kind, store) = if discovered.join(".jj").exists() { + let mut cmd = jj_command(discovered); + cmd.arg("root"); + let output = checked(run(cmd, vec![], &cancelled)?)?; + let actual = path_bytes(output.strip_suffix(b"\n").unwrap_or(&output))?; + let store = store_for_jj(&actual)?; + (actual, RepoKind::Jujutsu, store) + } else { + let actual = discovered.to_path_buf(); + let mut cmd = Command::new("git"); + cmd.arg("-C") + .arg(&actual) + .args(["rev-parse", "--absolute-git-dir"]) + .env("GIT_OPTIONAL_LOCKS", "0"); + let output = checked(run(cmd, vec![], &cancelled)?)?; + let store = path_bytes(output.strip_suffix(b"\n").unwrap_or(&output))?; + (actual, RepoKind::Git, store) + }; + Ok(Self { + root, + kind, + store, + temporary: Temporary::new()?, + private: None, + remotes: vec![], + remote_refs: BTreeMap::new(), + loaded: BTreeSet::new(), + registered: BTreeSet::new(), + cancelled, + }) + } + fn git(&self, store: &Path, args: &[&str]) -> Result, String> { + let mut cmd = git_command(store); + cmd.args(args).current_dir(&self.root); + checked(run(cmd, vec![], &self.cancelled)?) + } + fn jj(&self, root: &Path, args: &[&str]) -> Result, String> { + let mut cmd = jj_command(root); + cmd.args(args); + checked(run(cmd, vec![], &self.cancelled)?) + } + fn check_cancelled(&self) -> Result<(), String> { + if self.cancelled.load(Ordering::Acquire) { + Err("Diff operation cancelled".into()) + } else { + Ok(()) + } + } + fn revisions( + &self, + root: &Path, + store: &Path, + entries: &mut Vec, + notices: &mut Vec, + ) -> Result<(), String> { + if self.kind == RepoKind::Jujutsu { + let template = r#""[" ++ json(commit_id) ++ "," ++ json(change_id) ++ "," ++ json(description) ++ "," ++ json(local_bookmarks.map(|b| b.name())) ++ "," ++ json(remote_bookmarks.map(|b| stringify(b.name() ++ "@" ++ b.remote()))) ++ "]\n""#; + let revset = if root == self.root { + "all()" + } else { + "::remote_bookmarks() | ::remote_tags()" + }; + let output = + text(self.jj(root, &["log", "--no-graph", "-r", revset, "-T", template])?)?; + for line in output.lines() { + let (id, change, description, mut bookmarks, remote_bookmarks): ( + String, + String, + String, + Vec, + Vec, + ) = serde_json::from_str(line) + .map_err(|e| format!("Cannot decode Jujutsu revision: {e}"))?; + bookmarks.extend(remote_bookmarks); + let target = RevisionTarget::Commit(id.clone()); + entries.push(Revision { + target: target.clone(), + label: format!("{change} {id}"), + description: description.trim_end_matches('\n').into(), + category: Category::Revisions, + }); + for name in bookmarks { + entries.push(Revision { + target: target.clone(), + label: name, + description: format!("{change} {id} {}", description.trim_end()), + category: Category::Bookmarks, + }); + } + } + } else { + let output = self.git( + store, + &[ + "log", + "--no-color", + "--no-show-signature", + "--no-decorate", + "--all", + "--format=%H%x00%B%x00", + ], + )?; + let mut records = output.split(|b| *b == 0); + while let Some(id) = records.next() { + let id = std::str::from_utf8(id) + .map_err(|_| "Invalid Git commit ID")? + .trim(); + if id.is_empty() { + continue; + } + let description = records.next().ok_or("Truncated Git log")?; + entries.push(Revision { + target: RevisionTarget::Commit(id.into()), + label: id.into(), + description: String::from_utf8_lossy(description).trim_end().into(), + category: Category::Commits, + }); + } + let references = text(self.git( + store, + &[ + "for-each-ref", + "--format=%(refname)", + "refs/heads", + "refs/remotes", + "refs/tags", + ], + )?)?; + let mut request = Vec::new(); + for reference in references.lines() { + request.extend_from_slice(reference.as_bytes()); + request.extend_from_slice(b"^{commit}\n"); + } + let mut cmd = git_command(store); + cmd.args(["cat-file", "--batch-check"]); + let resolved = text(checked(run(cmd, request, &self.cancelled)?)?)?; + let mut records = resolved.lines(); + for reference in references.lines() { + let record = records.next().ok_or("Truncated Git reference metadata")?; + let mut fields = record.split_whitespace(); + let id = fields.next().ok_or("Missing Git reference object ID")?; + if fields.next() != Some("commit") { + notices.push(format!( + "{reference} does not point to a commit; non-commit tags cannot be compared" + )); + continue; + } + let category = if reference.starts_with("refs/tags/") { + Category::Tags + } else { + Category::Branches + }; + let label = reference + .strip_prefix("refs/heads/") + .or_else(|| reference.strip_prefix("refs/remotes/")) + .or_else(|| reference.strip_prefix("refs/tags/")) + .unwrap_or(reference); + entries.push(Revision { + target: RevisionTarget::Commit(id.into()), + label: label.into(), + description: format!("{reference} {id}"), + category, + }); + } + } + Ok(()) + } + fn discover_remotes(&mut self) -> Result<(), String> { + let mut remotes = vec![]; + if self.kind == RepoKind::Jujutsu { + for line in text(self.jj(&self.root, &["git", "remote", "list"])?)?.lines() { + let (name, url) = line + .split_once(char::is_whitespace) + .ok_or("Malformed Jujutsu remote metadata")?; + remotes.push(Remote { + name: name.into(), + url: url.trim_start().into(), + }); + } + } else { + for name in text(self.git(&self.store, &["remote"])?)?.lines() { + let url = text(self.git(&self.store, &["remote", "get-url", name])?)? + .trim_end_matches('\n') + .to_owned(); + remotes.push(Remote { + name: name.into(), + url, + }); + } + } + for remote in &mut remotes { + if !remote.url.contains(':') && !Path::new(&remote.url).is_absolute() { + remote.url = self.root.join(&remote.url).to_string_lossy().into_owned(); + } + } + self.remotes = remotes; + Ok(()) + } + fn advertised(&self, url: &str) -> Result, String> { + let mut cmd = Command::new("git"); + cmd.args(["ls-remote", "--refs", "--heads", "--tags", "--", url]) + .current_dir(&self.root) + .env("GIT_TERMINAL_PROMPT", "0"); + let output = text(checked(run(cmd, vec![], &self.cancelled)?)?)?; + output + .lines() + .map(|line| { + let (id, reference) = line + .split_once('\t') + .ok_or("Malformed remote reference metadata")?; + if !matches!(id.len(), 40 | 64) + || !id.bytes().all(|b| b.is_ascii_hexdigit()) + || !(reference.starts_with("refs/heads/") + || reference.starts_with("refs/tags/")) + { + return Err("Invalid remote reference metadata".into()); + } + Ok((reference.into(), id.into())) + }) + .collect() + } + pub fn catalog(&mut self) -> Result { + self.check_cancelled()?; + self.discover_remotes()?; + let category = if self.kind == RepoKind::Jujutsu { + Category::Revisions + } else { + Category::Commits + }; + let mut entries = vec![Revision { + target: RevisionTarget::Working, + label: "Working checkout".into(), + description: + "On-disk tracked and eligible untracked files; unsaved buffers are excluded".into(), + category, + }]; + let mut notices = vec![]; + self.revisions(&self.root, &self.store, &mut entries, &mut notices)?; + if let Some((root, store)) = &self.private { + self.revisions(root, store, &mut entries, &mut notices)?; + } + for remote in &self.remotes { + entries.push(Revision { + target: RevisionTarget::FetchRemote(remote.name.clone()), + label: format!("Load history from {}", remote.name), + description: "Fetch reachable history into a private comparison repository".into(), + category, + }); + match self.advertised(&remote.url) { + Ok(refs) => { + self.remote_refs.retain(|(name, _), _| name != &remote.name); + for (reference, id) in refs { + let tag = reference.starts_with("refs/tags/"); + let name = reference + .strip_prefix(if tag { "refs/tags/" } else { "refs/heads/" }) + .unwrap(); + if !tag || self.kind == RepoKind::Git { + entries.push(Revision { + target: RevisionTarget::Remote { + remote: remote.name.clone(), + reference: reference.clone(), + }, + label: format!("{name}@{}", remote.name), + description: id.clone(), + category: if tag { + Category::Tags + } else if self.kind == RepoKind::Jujutsu { + Category::Bookmarks + } else { + Category::Branches + }, + }); + } + self.remote_refs + .insert((remote.name.clone(), reference), id); + } + } + Err(e) => notices.push(format!("{}: {e}", remote.name)), + } + } + self.check_cancelled()?; + let mut seen = BTreeSet::new(); + entries.retain(|entry| { + let key = format!("{:?}|{:?}|{}", entry.target, entry.category, entry.label); + seen.insert(key) + }); + Ok(Catalog { + kind: self.kind, + entries, + notice: if notices.is_empty() { + None + } else { + Some(notices.join("; ")) + }, + }) + } + fn ensure_private(&mut self) -> Result<(), String> { + if self.private.is_some() { + return Ok(()); + } + let root = self.temporary.0.join("repository"); + if self.kind == RepoKind::Jujutsu { + let mut cmd = Command::new("jj"); + cmd.args([ + "--no-pager", + "--color=never", + "git", + "init", + "--no-colocate", + ]) + .arg(&root); + checked(run(cmd, vec![], &self.cancelled)?)?; + let store = store_for_jj(&root)?; + self.private = Some((root, store)); + } else { + let format = text(self.git(&self.store, &["rev-parse", "--show-object-format"])?)?; + let mut cmd = Command::new("git"); + cmd.args(["init", "--bare"]) + .arg(format!("--object-format={}", format.trim())) + .arg(&root); + checked(run(cmd, vec![], &self.cancelled)?)?; + self.private = Some((root.clone(), root)); + } + Ok(()) + } + fn load_remote(&mut self, name: &str) -> Result<(), String> { + if self.remotes.is_empty() { + self.discover_remotes()?; + } + let url = self + .remotes + .iter() + .find(|r| r.name == name) + .ok_or_else(|| format!("Unknown configured remote: {name}"))? + .url + .clone(); + self.ensure_private()?; + let (root, store) = self.private.as_ref().unwrap(); + if self.kind == RepoKind::Jujutsu { + if !self.registered.contains(name) { + self.jj( + root, + &["git", "remote", "add", "--fetch-tags=all", "--", name, &url], + )?; + self.registered.insert(name.into()); + } + self.jj( + root, + &[ + "git", + "fetch", + "--remote", + &format!("exact:{name}"), + "--branch", + "glob:*", + ], + )?; + } else { + self.git( + store, + &[ + "fetch", + "--no-write-fetch-head", + "--", + &url, + &format!("+refs/heads/*:refs/remotes/{name}/*"), + &format!("+refs/tags/*:refs/vamp-tags/{name}/*"), + ], + )?; + } + self.loaded.insert(name.into()); + let refs = self.advertised(&url)?; + self.remote_refs.retain(|(remote, _), _| remote != name); + for (reference, id) in refs { + self.remote_refs.insert((name.into(), reference), id); + } + Ok(()) + } + pub fn fetch_remote(&mut self, remote: &str) -> Result { + self.load_remote(remote)?; + self.catalog() + } + fn resolve(&mut self, target: &RevisionTarget) -> Result<(PathBuf, String), String> { + let id = match target { + RevisionTarget::Commit(id) => { + if !matches!(id.len(), 40 | 64) + || (self.kind == RepoKind::Jujutsu && id.len() != 40) + || !id.bytes().all(|b| b.is_ascii_hexdigit()) + { + return Err( + "Invalid immutable commit ID (expected a full hexadecimal object ID)" + .into(), + ); + } + if id.bytes().all(|b| b == b'0') && self.kind == RepoKind::Jujutsu { + return Ok((self.store.clone(), id.clone())); + } + id.clone() + } + RevisionTarget::Remote { remote, reference } => { + if !self.loaded.contains(remote) { + self.load_remote(remote)?; + } + self.remote_refs + .get(&(remote.clone(), reference.clone())) + .ok_or_else(|| { + format!("Remote reference is no longer advertised: {reference}@{remote}") + })? + .clone() + } + RevisionTarget::Working => return Err("Working checkout is not a commit".into()), + RevisionTarget::FetchRemote(_) => { + return Err("Select a revision, not a history loader".into()); + } + }; + for store in + std::iter::once(&self.store).chain(self.private.as_ref().map(|(_, store)| store)) + { + let mut cmd = git_command(store); + cmd.args(["rev-parse", "--verify", &format!("{id}^{{commit}}")]) + .current_dir(&self.root); + let output = run(cmd, vec![], &self.cancelled)?; + if output.code == Some(0) { + return Ok((store.clone(), text(output.stdout)?.trim().into())); + } + if !matches!(output.code, Some(1 | 128)) { + checked(output)?; + } + } + Err(format!( + "Commit {id} is unavailable locally or in loaded remote history" + )) + } + fn conflicted_snapshot(&self, root: &Path, id: &str) -> Result { + let revset = format!("commit_id({})", serde_json::to_string(id).unwrap()); + let template = r#""[" ++ json(path) ++ "," ++ json(file_type) ++ "," ++ json(executable) ++ "," ++ json(conflict) ++ "]\n""#; + let entries = text(self.jj(root, &["file", "list", "-r", &revset, "-T", template])?)?; + let mut snapshot = BTreeMap::new(); + let mut total = 0; + for entry in entries.lines() { + self.check_cancelled()?; + let (path, kind, executable, conflict): (String, String, bool, bool) = + serde_json::from_str(entry) + .map_err(|e| format!("Cannot decode Jujutsu conflict entry: {e}"))?; + let path_buf = PathBuf::from(&path); + safe_path(&path_buf)?; + let mut notice = conflict.then(|| { + "Unresolved Jujutsu conflict: showing CLI-rendered conflict projection".to_owned() + }); + let mode = if kind == "symlink" { + "120000" + } else if kind == "git-submodule" { + "160000" + } else if executable { + "100755" + } else { + "100644" + }; + let bytes = if matches!(kind.as_str(), "file" | "symlink" | "conflict") { + // Use an exact fileset and jj's own conflict materialization. + let mut pattern = String::from("root-file:\""); + for ch in path.chars() { + match ch { + '\\' => pattern.push_str("\\\\"), + '"' => pattern.push_str("\\\""), + '\0'..='\u{1f}' | '\u{7f}' => { + use std::fmt::Write as _; + write!(&mut pattern, "\\x{:02x}", ch as u32).unwrap(); + } + _ => pattern.push(ch), + } + } + pattern.push('"'); + self.jj( + root, + &["file", "show", "-r", &revset, "-T", "", "--", &pattern], + )? + } else { + notice = Some(format!( + "Unsupported {kind} content in a conflicted revision; nested contents are not traversed" + )); + vec![] + }; + if bytes.len() > FILE_LIMIT { + return Err(format!( + "{} exceeds the 16 MiB comparison file limit", + path_buf.display() + )); + } + total += bytes.len(); + if total > OUTPUT_LIMIT { + return Err( + "Jujutsu conflict projection exceeds the 64 MiB comparison limit".into(), + ); + } + snapshot.insert( + path_buf, + FileData { + mode: mode.into(), + bytes, + notice, + }, + ); + } + Ok(snapshot) + } + fn commit_snapshot(&self, store: &Path, id: &str) -> Result { + if id.bytes().all(|b| b == b'0') { + return Ok(BTreeMap::new()); + } + // Jujutsu stores unresolved merge trees specially; do not show their raw Git encoding. + if self.kind == RepoKind::Jujutsu { + let root = if store == self.store { + &self.root + } else { + &self.private.as_ref().unwrap().0 + }; + let conflict = text(self.jj( + root, + &[ + "log", + "--no-graph", + "-r", + &format!("commit_id({})", serde_json::to_string(id).unwrap()), + "-T", + "conflict", + ], + )?)?; + if conflict.trim() == "true" { + return self.conflicted_snapshot(root, id); + } + } + let output = self.git(store, &["ls-tree", "-r", "-z", "--full-tree", id])?; + let mut entries = vec![]; + let mut request = Vec::new(); + for record in output.split(|b| *b == 0).filter(|r| !r.is_empty()) { + let tab = record + .iter() + .position(|b| *b == b'\t') + .ok_or("Malformed Git tree")?; + let header = + std::str::from_utf8(&record[..tab]).map_err(|_| "Malformed Git tree header")?; + let fields: Vec<_> = header.split(' ').collect(); + if fields.len() != 3 { + return Err("Malformed Git tree entry".into()); + } + let path = path_bytes(&record[tab + 1..])?; + safe_path(&path)?; + if fields[1] == "commit" { + entries.push(( + path, + fields[0].to_owned(), + None, + Some(format!( + "Submodule commit {} (contents are not traversed)", + fields[2] + )), + )); + } else if fields[1] == "blob" { + request.extend_from_slice(fields[2].as_bytes()); + request.push(b'\n'); + entries.push((path, fields[0].to_owned(), Some(fields[2].to_owned()), None)); + } else { + return Err(format!("Unsupported tree entry type: {}", fields[1])); + } + } + let mut cmd = git_command(store); + cmd.arg("cat-file").arg("--batch"); + let data = checked(run(cmd, request, &self.cancelled)?)?; + let mut offset = 0; + let mut snapshot = BTreeMap::new(); + for (path, mode, object, notice) in entries { + self.check_cancelled()?; + let bytes = if object.is_some() { + let end = offset + + data[offset..] + .iter() + .position(|b| *b == b'\n') + .ok_or("Truncated Git object header")?; + let header = std::str::from_utf8(&data[offset..end]) + .map_err(|_| "Invalid Git object header")?; + let size: usize = header + .rsplit(' ') + .next() + .ok_or("Missing Git object size")? + .parse() + .map_err(|_| "Invalid Git object size")?; + if size > FILE_LIMIT { + return Err(format!( + "{} exceeds the 16 MiB comparison file limit", + path.display() + )); + } + offset = end + 1; + if size >= data.len().saturating_sub(offset) { + return Err("Truncated Git object content".into()); + } + let bytes = data[offset..offset + size].to_vec(); + offset += size + 1; + bytes + } else { + vec![] + }; + snapshot.insert( + path, + FileData { + mode, + bytes, + notice, + }, + ); + } + Ok(snapshot) + } + fn working_snapshot(&self) -> Result { + let mut tracked = BTreeSet::new(); + let mut submodules = BTreeSet::new(); + if self.kind == RepoKind::Git { + for record in self + .git(&self.store, &["ls-files", "--stage", "-z"])? + .split(|b| *b == 0) + .filter(|p| !p.is_empty()) + { + let tab = record + .iter() + .position(|b| *b == b'\t') + .ok_or("Malformed Git index entry")?; + let path = path_bytes(&record[tab + 1..])?; + if record.starts_with(b"160000 ") { + submodules.insert(path.clone()); + } + tracked.insert(path); + } + } else { + let output = text(self.jj( + &self.root, + &[ + "file", + "list", + "-T", + "\"[\" ++ json(path) ++ \",\" ++ json(file_type) ++ \"]\\n\"", + ], + )?)?; + for line in output.lines() { + let (path, kind): (String, String) = + serde_json::from_str(line).map_err(|e| e.to_string())?; + let path = PathBuf::from(path); + if kind == "git-submodule" { + submodules.insert(path.clone()); + } + tracked.insert(path); + } + } + let mut paths = tracked; + if self.kind == RepoKind::Git { + for path in self + .git( + &self.store, + &["ls-files", "--others", "--exclude-standard", "-z"], + )? + .split(|b| *b == 0) + .filter(|p| !p.is_empty()) + { + paths.insert(path_bytes(path)?); + } + } else { + let excluded = submodules.clone(); + let checkout = self.root.clone(); + let walker = ignore::WalkBuilder::new(&self.root) + .hidden(false) + .ignore(false) + .parents(false) + .require_git(false) + .follow_links(false) + .filter_entry(move |entry| { + entry.file_name() != OsStr::new(".git") + && entry.file_name() != OsStr::new(".jj") + && !entry + .path() + .strip_prefix(&checkout) + .is_ok_and(|path| excluded.contains(path)) + }) + .build(); + for entry in walker { + self.check_cancelled()?; + let entry = entry.map_err(|e| e.to_string())?; + if entry.file_type().is_some_and(|kind| !kind.is_dir()) { + paths.insert( + entry + .path() + .strip_prefix(&self.root) + .map_err(|e| e.to_string())? + .to_path_buf(), + ); + } + } + } + let mut total = 0; + let mut snapshot = BTreeMap::new(); + for path in paths { + self.check_cancelled()?; + safe_path(&path)?; + let full = self.root.join(&path); + // Never follow an intermediate symlink outside the checkout. + let mut parent = full.parent(); + let mut blocked = false; + while let Some(p) = parent { + if p == self.root { + break; + } + if fs::symlink_metadata(p).is_ok_and(|m| m.file_type().is_symlink()) { + blocked = true; + break; + } + parent = p.parent(); + } + if blocked { + continue; + } // The parent symlink itself is the on-disk replacement. + let metadata = match fs::symlink_metadata(&full) { + Ok(m) => m, + Err(e) + if e.kind() == std::io::ErrorKind::NotFound + || e.kind() == std::io::ErrorKind::NotADirectory => + { + continue; + } + Err(e) => return Err(format!("{}: {e}", path.display())), + }; + if metadata.is_dir() { + if submodules.contains(&path) || full.join(".git").exists() { + let notice = if full.join(".git").exists() { + let mut cmd = Command::new("git"); + cmd.arg("-C") + .arg(&full) + .args(["rev-parse", "--verify", "HEAD"]) + .env("GIT_OPTIONAL_LOCKS", "0"); + let output = run(cmd, vec![], &self.cancelled)?; + if output.code == Some(0) { + format!( + "Submodule commit {} (contents are not traversed)", + text(output.stdout)?.trim() + ) + } else { + "Submodule revision is unavailable; contents are not traversed".into() + } + } else { + "Submodule directory is not initialized; contents are not traversed".into() + }; + snapshot.insert( + path, + FileData { + mode: "160000".into(), + bytes: vec![], + notice: Some(notice), + }, + ); + } + continue; + } + let (mode, bytes, notice) = if metadata.file_type().is_symlink() { + let target = fs::read_link(&full).map_err(|e| e.to_string())?; + #[cfg(unix)] + let bytes = { + use std::os::unix::ffi::OsStrExt; + target.as_os_str().as_bytes().to_vec() + }; + #[cfg(not(unix))] + let bytes = target.to_string_lossy().as_bytes().to_vec(); + ("120000".into(), bytes, None) + } else if metadata.is_file() { + if metadata.len() > FILE_LIMIT as u64 { + return Err(format!( + "{} exceeds the 16 MiB comparison file limit", + path.display() + )); + } + let bytes = fs::File::open(&full) + .and_then(|f| { + let mut bytes = Vec::new(); + f.take(FILE_LIMIT as u64 + 1).read_to_end(&mut bytes)?; + Ok(bytes) + }) + .map_err(|e| format!("{}: {e}", path.display()))?; + if bytes.len() > FILE_LIMIT { + return Err(format!( + "{} grew beyond the 16 MiB comparison file limit", + path.display() + )); + } + #[cfg(unix)] + let executable = { + use std::os::unix::fs::PermissionsExt; + metadata.permissions().mode() & 0o111 != 0 + }; + #[cfg(not(unix))] + let executable = false; + ( + if executable { "100755" } else { "100644" }.into(), + bytes, + None, + ) + } else { + ( + "unsupported".into(), + vec![], + Some("Unsupported on-disk file type; contents are not read".into()), + ) + }; + total += bytes.len(); + if total > OUTPUT_LIMIT { + return Err("Working snapshot exceeds the 64 MiB comparison limit".into()); + } + snapshot.insert( + path, + FileData { + mode, + bytes, + notice, + }, + ); + } + Ok(snapshot) + } + fn snapshot(&mut self, target: &RevisionTarget) -> Result { + if *target == RevisionTarget::Working { + 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 { + let left = self.snapshot(&before.target)?; + let right = if before.target == after.target { + None + } else { + Some(self.snapshot(&after.target)?) + }; + let right = right.as_ref().unwrap_or(&left); + let mut files = vec![]; + let paths: BTreeSet<_> = left.keys().chain(right.keys()).collect(); + for path in paths { + self.check_cancelled()?; + let a = left.get(path); + let b = right.get(path); + if a == b { + continue; + } + files.push(self.changed_file(path, a, b)?); + } + // Match only identical data; modified-content moves remain add/remove pairs. + let mut candidates: BTreeMap<&FileData, Vec> = BTreeMap::new(); + for (i, file) in files.iter().enumerate() { + if file.status == FileStatus::Removed { + candidates.entry(&left[&file.path]).or_default().push(i); + } + } + let mut removed = BTreeSet::new(); + for i in 0..files.len() { + if files[i].status != FileStatus::Added { + continue; + } + if let Some(j) = candidates + .get_mut(&right[&files[i].path]) + .and_then(Vec::pop) + { + let old_path = files[j].path.clone(); + let mut renamed = self.changed_file( + &files[i].path, + left.get(&old_path), + right.get(&files[i].path), + )?; + renamed.status = FileStatus::Renamed; + renamed.old_path = Some(old_path); + files[i] = renamed; + removed.insert(j); + } + } + let files = files + .into_iter() + .enumerate() + .filter_map(|(i, f)| (!removed.contains(&i)).then_some(f)) + .collect(); + Ok(Comparison { + before: before.label.clone(), + after: after.label.clone(), + files, + }) + } + fn changed_file( + &self, + path: &Path, + before: Option<&FileData>, + after: Option<&FileData>, + ) -> Result { + let status = if before.is_none() { + FileStatus::Added + } else if after.is_none() { + FileStatus::Removed + } else { + FileStatus::Modified + }; + let a = before.map_or(&[][..], |f| f.bytes.as_slice()); + let b = after.map_or(&[][..], |f| f.bytes.as_slice()); + let mut notices: Vec = before + .into_iter() + .chain(after) + .filter_map(|f| f.notice.clone()) + .collect(); + if before.into_iter().chain(after).any(|f| f.mode == "120000") { + notices.push("Symbolic link: showing link target, never following it".into()); + } + if let (Some(a), Some(b)) = (before, after) + && a.mode != b.mode + { + notices.push(format!("File mode changed: {} → {}", a.mode, b.mode)); + } + let binary = a.contains(&0) + || b.contains(&0) + || std::str::from_utf8(a).is_err() + || std::str::from_utf8(b).is_err(); + let (before_lines, after_lines, hunks) = if binary { + notices.push("Binary or non-UTF-8 content; text diff is unavailable".into()); + (vec![], vec![], vec![]) + } else { + if (!a.is_empty() && !a.ends_with(b"\n")) || (!b.is_empty() && !b.ends_with(b"\n")) { + notices.push(format!( + "EOF newline: Before {}, After {}", + eof_label(before), + 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) + }; + notices.dedup(); + Ok(ChangedFile { + path: path.to_path_buf(), + old_path: None, + status, + before: before_lines, + after: after_lines, + hunks, + notice: if notices.is_empty() { + None + } else { + Some(notices.join("; ")) + }, + }) + } +} +fn lines(bytes: &[u8]) -> Vec { + if bytes.is_empty() { + return vec![]; + } + let text = std::str::from_utf8(bytes).unwrap(); + let text = text.strip_suffix('\n').unwrap_or(text); + text.split('\n').map(str::to_owned).collect() +} +fn eof_label(file: Option<&FileData>) -> &'static str { + match file { + None => "absent", + Some(f) if f.bytes.is_empty() => "empty file", + Some(f) if f.bytes.ends_with(b"\n") => "present", + 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 { + use super::*; + + #[test] + fn cancellation_kills_pipe_holding_children_and_drains_both_streams() { + let temp = Temporary::new().unwrap(); + let ready = temp.0.join("ready"); + let cancelled = Arc::new(AtomicBool::new(false)); + let worker_cancelled = Arc::clone(&cancelled); + let gate = ready.clone(); + let (sent, received) = mpsc::channel(); + let worker = thread::spawn(move || { + let mut command = Command::new("sh"); + command + .args([ + "-c", + "sleep 600 & printf ready > \"$1\"; wait", + "vamp-diff-test", + ]) + .arg(gate); + let result = run(command, vec![], &worker_cancelled).map(|_| ()); + sent.send(result).unwrap(); + }); + // File handshake, not a wall-clock scheduling assumption. + while !ready.exists() { + if let Ok(result) = received.try_recv() { + panic!("Process exited before the child was ready: {result:?}"); + } + thread::yield_now(); + } + cancelled.store(true, Ordering::Release); + assert!(received.recv().unwrap().unwrap_err().contains("cancelled")); + worker.join().unwrap(); + + let mut command = Command::new("sh"); + command.args(["-c", "i=0; while [ \"$i\" -lt 10000 ]; do printf 'line\\n'; printf 'error\\n' >&2; i=$((i+1)); done"]); + let result = run(command, vec![], &AtomicBool::new(false)).unwrap(); + assert_eq!(result.code, Some(0)); + assert_eq!(result.stdout.len(), 50_000); + assert_eq!(result.stderr.len(), 60_000); + } +} diff --git a/src/session.rs b/src/session.rs index c2b2d07..563fc06 100644 --- a/src/session.rs +++ b/src/session.rs @@ -2,18 +2,125 @@ use std::{ collections::BTreeMap, io, path::{Path, PathBuf}, + sync::{ + Arc, + atomic::{AtomicBool, AtomicU64, Ordering}, + mpsc::{self, Receiver, SyncSender}, + }, + thread::{self, JoinHandle}, }; use crate::{ + diff::{Operation as DiffOperation, Outcome as DiffOutcome}, editor::{Cursor, Editor, Effect, Input, Invocation, Mode}, lsp::Lsp, plugin::{ DocumentEvent, DocumentEventKind, DocumentSnapshot, PluginEvent, PluginUpdate, TextPosition, UpdateEffect, }, + repository::Repository, ruby::{Config, RubyPlugin, load_config}, }; +type DiffResult = (u64, Result); + +struct DiffWorker { + requests: Option>, + results: Option>, + cancelled: Arc, + stopped: Arc, + cancelled_request: Arc, + thread: Option>, + pending: Option, +} + +impl DiffWorker { + fn start(root: PathBuf) -> Result { + 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 { snapshot: DocumentSnapshot, save_revision: u64, @@ -45,6 +152,7 @@ pub struct EditorSession { pending_hover: bool, pending_completion: bool, hover_owner: Option, + diff_worker: Option, } impl EditorSession { @@ -135,6 +243,7 @@ impl EditorSession { pending_hover: false, pending_completion: false, hover_owner: None, + diff_worker: None, }; session.synchronize_documents(); Ok(session) @@ -152,6 +261,7 @@ impl EditorSession { self.dispatch(invocation); self.synchronize_documents(); } + self.start_diff_request(); } pub fn execute(&mut self, command: &str) { @@ -162,6 +272,82 @@ impl EditorSession { self.dispatch(invocation); 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) { @@ -368,6 +554,9 @@ impl EditorSession { request_id: Option, source: InvocationSource, ) -> bool { + if self.editor.diff().is_some() { + return false; + } self.editor.buffer_id() == source.buffer_id && self.source_is_current(source) && (owner != "core" @@ -617,7 +806,10 @@ 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 { + if !self.source_is_current(source) + || self.editor.buffer_id() != source.buffer_id + || self.editor.diff().is_some() + { return Ok(false); } if effects @@ -641,6 +833,7 @@ impl EditorSession { /// Drains ready plugin work without waiting for command or server responses. pub fn poll(&mut self) -> bool { let mut changed = self.synchronize_documents(); + changed |= self.poll_diff(); for update in self.lsp.poll() { match self.apply_update("core", update) { Ok(applied) => changed |= applied, @@ -3076,4 +3269,55 @@ end assert_eq!(invokes[0]["column"], 2); 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"); + } } diff --git a/src/tui.rs b/src/tui.rs index 3b1a463..af70654 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -25,13 +25,14 @@ use ratatui::{ backend::CrosstermBackend, layout::{Constraint, Layout, Position, Rect}, style::{Color, Modifier, Style}, - text::Line, + text::{Line, Span}, widgets::{Block, Borders, Clear, Paragraph}, }; use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthStr; use crate::{ + diff::{ChangedFile, DiffState, FileStatus, Focus, Phase, RowKind}, editor::{ Completion, Cursor, Editor, Hover, Input, Mode, Palette, Selection, StartupDiagnostic, }, @@ -414,8 +415,18 @@ fn draw( ) { panels.begin_frame(); document.geometry = None; - syntax.update(editor.lines(), editor.path(), editor.text_revision()); 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 { 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 = 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 = 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 = 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::(), + ) + } 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 { let [_, column, _] = Layout::horizontal([ Constraint::Fill(1), @@ -939,6 +1405,7 @@ struct WheelPanels { hover_syntax: SyntaxCache, files: Option<(Rect, usize, usize)>, manual_files: Option, + diff: Option, } struct FileWheelOffset { @@ -973,6 +1440,7 @@ impl WheelPanels { self.hover = None; self.completion = None; self.files = None; + self.diff = None; } // Keyboard/paste may change the catalog without changing its length. @@ -1008,6 +1476,37 @@ impl WheelPanels { _ => return false, }; 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() { return true; } @@ -1640,6 +2139,375 @@ mod tests { .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) -> 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, + 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] fn completion_windows_selected_unicode_without_overwriting_gutter_dock_or_cursor() { let mut completion = Completion { diff --git a/tests/diff.rs b/tests/diff.rs new file mode 100644 index 0000000..99f666c --- /dev/null +++ b/tests/diff.rs @@ -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"); +} diff --git a/tests/repository.rs b/tests/repository.rs new file mode 100644 index 0000000..559c6a2 --- /dev/null +++ b/tests/repository.rs @@ -0,0 +1,1022 @@ +#![cfg(unix)] + +use std::{ + collections::BTreeMap, + ffi::OsString, + fs, + os::unix::{ + ffi::OsStringExt, + fs::{PermissionsExt, symlink}, + }, + path::{Path, PathBuf}, + process::{Command, Output}, + sync::{ + Arc, + atomic::{AtomicBool, AtomicU64, Ordering}, + }, +}; +use vamp::{ + diff::{Category, Comparison, FileStatus, RepoKind, Revision, RevisionTarget}, + repository::Repository, +}; + +static NEXT: AtomicU64 = AtomicU64::new(0); +struct Fixture(PathBuf); +impl Fixture { + fn new() -> Self { + let path = std::env::temp_dir().join(format!( + "vamp-repository-test-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir(&path).unwrap(); + Self(path) + } + fn dir(&self, name: &str) -> PathBuf { + let path = self.0.join(name); + fs::create_dir(&path).unwrap(); + path + } +} +impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} +fn command(program: &str, root: &Path, args: &[&str]) -> Output { + Command::new(program) + .current_dir(root) + .args(args) + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .env("GIT_OPTIONAL_LOCKS", "0") + .env("GIT_TERMINAL_PROMPT", "0") + .env("GIT_AUTHOR_DATE", "2000-01-01T00:00:00Z") + .env("GIT_COMMITTER_DATE", "2000-01-01T00:00:00Z") + .env("JJ_CONFIG", "/dev/null") + .env("JJ_USER", "Diff Test") + .env("JJ_EMAIL", "diff@example.invalid") + .output() + .unwrap() +} +fn success(program: &str, root: &Path, args: &[&str]) -> String { + let result = command(program, root, args); + assert!( + result.status.success(), + "{program} {args:?}: {}", + String::from_utf8_lossy(&result.stderr) + ); + String::from_utf8(result.stdout) + .unwrap() + .trim_end_matches('\n') + .into() +} +fn git(root: &Path, args: &[&str]) -> String { + success("git", root, args) +} +fn jj(root: &Path, args: &[&str]) -> String { + success("jj", root, args) +} +fn init_git(root: &Path) { + git(root, &["init", "--initial-branch=local"]); + git(root, &["config", "user.name", "Diff Test"]); + git(root, &["config", "user.email", "diff@example.invalid"]); +} +fn init_jj(root: &Path, colocate: bool) { + jj( + root, + &[ + "git", + "init", + if colocate { + "--colocate" + } else { + "--no-colocate" + }, + ], + ); + jj(root, &["config", "set", "--repo", "user.name", "Diff Test"]); + jj( + root, + &[ + "config", + "set", + "--repo", + "user.email", + "diff@example.invalid", + ], + ); +} +fn open(root: &Path) -> Repository { + Repository::open(root, Arc::new(AtomicBool::new(false))).unwrap() +} +fn revision(id: &str, kind: RepoKind) -> Revision { + Revision { + target: RevisionTarget::Commit(id.into()), + label: id.into(), + description: String::new(), + category: if kind == RepoKind::Jujutsu { + Category::Revisions + } else { + Category::Commits + }, + } +} +fn working(kind: RepoKind) -> Revision { + let mut r = revision("working", kind); + r.target = RevisionTarget::Working; + r +} +fn changed<'a>(comparison: &'a Comparison, path: &Path) -> &'a vamp::diff::ChangedFile { + comparison + .files + .iter() + .find(|f| f.path == path) + .unwrap_or_else(|| panic!("Missing changed file {path:?}: {:?}", comparison.files)) +} +// Capture active metadata, checkout bytes, executable bits and link targets. Access +// times are intentionally excluded: reading a repository must not change its data. +fn disk_state(root: &Path) -> BTreeMap)> { + fn visit(root: &Path, path: &Path, result: &mut BTreeMap)>) { + for entry in fs::read_dir(path).unwrap() { + let entry = entry.unwrap(); + let full = entry.path(); + let metadata = fs::symlink_metadata(&full).unwrap(); + if metadata.is_dir() { + visit(root, &full, result); + } else { + let data = if metadata.file_type().is_symlink() { + use std::os::unix::ffi::OsStrExt; + fs::read_link(&full) + .unwrap() + .as_os_str() + .as_bytes() + .to_vec() + } else { + fs::read(&full).unwrap() + }; + result.insert( + full.strip_prefix(root).unwrap().to_path_buf(), + (metadata.permissions().mode(), data), + ); + } + } + } + let mut result = BTreeMap::new(); + visit(root, root, &mut result); + result +} + +#[test] +fn git_working_reads_disk_and_handles_ranges_ignored_files_binary_links_and_odd_paths() { + let fixture = Fixture::new(); + let root = fixture.dir("git"); + init_git(&root); + for (path, data) in [ + ("text", &b"one\nthree\n"[..]), + ("removed", b"removed\n"), + ("empty-before", b""), + ("newline", b"same\n"), + ("crlf", b"a\r\n"), + ("rename-old", b"move\n"), + ("binary", b"\0old"), + ("ignored-tracked", b"old\n"), + ("executable", b"script\n"), + ("type-switch", b"target"), + (".gitignore", b"ignored-*\n"), + ] { + fs::write(root.join(path), data).unwrap(); + } + git(&root, &["add", "."]); + git(&root, &["add", "--force", "ignored-tracked"]); + git(&root, &["commit", "-m", "baseline"]); + git(&root, &["tag", "-a", "annotated", "-m", "tag"]); + git( + &root, + &["tag", "-a", "nested", "-m", "nested tag", "annotated"], + ); + let id = git(&root, &["rev-parse", "HEAD"]); + fs::write(root.join("text"), b"one\nstaged\nthree\n").unwrap(); + git(&root, &["add", "text"]); + fs::write(root.join("text"), b"one\ntwo\nthree\n").unwrap(); + fs::remove_file(root.join("removed")).unwrap(); + fs::write(root.join("empty-before"), b"first\n").unwrap(); + fs::write(root.join("empty-added"), b"").unwrap(); + fs::write(root.join("added"), b"added\n").unwrap(); + fs::write(root.join("newline"), b"same").unwrap(); + fs::write(root.join("crlf"), b"b\r\n").unwrap(); + fs::write(root.join("binary"), b"\0new").unwrap(); + fs::write(root.join("ignored-tracked"), b"changed\n").unwrap(); + fs::write(root.join("ignored-untracked"), b"must not appear\n").unwrap(); + fs::rename(root.join("rename-old"), root.join("rename-new")).unwrap(); + fs::remove_file(root.join("type-switch")).unwrap(); + symlink("target", root.join("type-switch")).unwrap(); + fs::set_permissions(root.join("executable"), fs::Permissions::from_mode(0o755)).unwrap(); + let odd = PathBuf::from("-tab\tand\nnewline"); + fs::write(root.join(&odd), b"odd\n").unwrap(); + let non_utf8 = PathBuf::from(OsString::from_vec(b"non-utf8-\xff".to_vec())); + let non_utf8_created = match fs::write(root.join(&non_utf8), b"bytes\n") { + Ok(()) => true, + // APFS rejects non-UTF-8 names before repository code can read them. + Err(error) if cfg!(target_os = "macos") && error.raw_os_error() == Some(92) => false, + Err(error) => panic!("Cannot create non-UTF-8 fixture: {error}"), + }; + symlink("/not/a/path/to/read", root.join("link")).unwrap(); + let state = disk_state(&root); + let mut repository = open(&root); + let catalog = repository.catalog().unwrap(); + assert_eq!(catalog.kind, RepoKind::Git); + assert_eq!(catalog.entries[0].target, RevisionTarget::Working); + assert!( + catalog + .entries + .iter() + .any(|r| r.label == id && r.description == "baseline") + ); + assert!(catalog.entries.iter().any(|r| r.label == "nested" + && r.category == Category::Tags + && r.target == RevisionTarget::Commit(id.clone()))); + let comparison = repository + .compare(&revision(&id, RepoKind::Git), &working(RepoKind::Git)) + .unwrap(); + let text = changed(&comparison, Path::new("text")); + assert_eq!(text.after, ["one", "two", "three"]); + assert_eq!( + ( + text.hunks[0].before_start, + text.hunks[0].before_len, + text.hunks[0].after_start, + text.hunks[0].after_len + ), + (1, 0, 1, 1) + ); + let empty = changed(&comparison, Path::new("empty-before")); + assert_eq!( + ( + empty.hunks[0].before_start, + empty.hunks[0].before_len, + empty.hunks[0].after_start, + empty.hunks[0].after_len + ), + (0, 0, 0, 1) + ); + let deleted = changed(&comparison, Path::new("removed")); + assert_eq!(deleted.status, FileStatus::Removed); + assert_eq!( + ( + deleted.hunks[0].before_start, + deleted.hunks[0].before_len, + deleted.hunks[0].after_start, + deleted.hunks[0].after_len + ), + (0, 1, 0, 0) + ); + assert_eq!( + changed(&comparison, Path::new("empty-added")).status, + FileStatus::Added + ); + assert!( + changed(&comparison, Path::new("empty-added")) + .after + .is_empty() + ); + assert_eq!(changed(&comparison, &odd).after, ["odd"]); + if non_utf8_created { + assert_eq!(changed(&comparison, &non_utf8).after, ["bytes"]); + } + assert_eq!(changed(&comparison, Path::new("crlf")).after, ["b\r"]); + assert!( + changed(&comparison, Path::new("newline")) + .notice + .as_ref() + .unwrap() + .contains("EOF newline") + ); + let type_switch = changed(&comparison, Path::new("type-switch")); + assert_eq!(type_switch.before, type_switch.after); + assert!( + type_switch + .notice + .as_ref() + .unwrap() + .contains("100644 → 120000") + ); + let binary = changed(&comparison, Path::new("binary")); + assert!(binary.before.is_empty() && binary.after.is_empty() && binary.hunks.is_empty()); + assert!(binary.notice.as_ref().unwrap().contains("Binary")); + assert_eq!( + changed(&comparison, Path::new("link")).after, + ["/not/a/path/to/read"] + ); + assert!( + changed(&comparison, Path::new("link")) + .notice + .as_ref() + .unwrap() + .contains("Symbolic link") + ); + assert!( + changed(&comparison, Path::new("executable")) + .notice + .as_ref() + .unwrap() + .contains("mode changed") + ); + assert_eq!( + changed(&comparison, Path::new("ignored-tracked")).after, + ["changed"] + ); + assert!( + !comparison + .files + .iter() + .any(|f| f.path == Path::new("ignored-untracked")) + ); + let moved = changed(&comparison, Path::new("rename-new")); + assert_eq!(moved.status, FileStatus::Renamed); + assert_eq!(moved.old_path.as_deref(), Some(Path::new("rename-old"))); + assert_eq!(moved.before, moved.after); + assert!(moved.hunks.is_empty()); + let reverse = repository + .compare(&working(RepoKind::Git), &revision(&id, RepoKind::Git)) + .unwrap(); + assert_eq!( + changed(&reverse, Path::new("added")).status, + FileStatus::Removed + ); + assert!( + repository + .compare(&working(RepoKind::Git), &working(RepoKind::Git)) + .unwrap() + .files + .is_empty() + ); + assert_eq!(disk_state(&root), state); +} + +#[test] +fn git_remote_refs_and_annotated_tags_are_discovered_before_fetch_and_history_stays_private() { + let fixture = Fixture::new(); + let source = fixture.dir("source"); + init_git(&source); + fs::write(source.join("file"), "ancestor\n").unwrap(); + git(&source, &["add", "."]); + git(&source, &["commit", "-m", "remote ancestor needle"]); + let ancestor = git(&source, &["rev-parse", "HEAD"]); + git(&source, &["tag", "-a", "ancestor-tag", "-m", "annotated"]); + fs::write(source.join("file"), "remote tip\n").unwrap(); + git(&source, &["commit", "-am", "remote tip"]); + let tip = git(&source, &["rev-parse", "HEAD"]); + git(&source, &["branch", "never-fetched"]); + let bare = fixture.0.join("remote.git"); + git( + &fixture.0, + &[ + "clone", + "--bare", + source.to_str().unwrap(), + bare.to_str().unwrap(), + ], + ); + let active = fixture.dir("active"); + init_git(&active); + fs::write(active.join("file"), "local\n").unwrap(); + git(&active, &["add", "."]); + git(&active, &["commit", "-m", "local"]); + let local = git(&active, &["rev-parse", "HEAD"]); + git( + &active, + &["remote", "add", "origin", bare.to_str().unwrap()], + ); + let state = disk_state(&active); + let mut repository = open(&active); + let catalog = repository.catalog().unwrap(); + let remote = catalog + .entries + .iter() + .find(|r| { + r.target + == (RevisionTarget::Remote { + remote: "origin".into(), + reference: "refs/heads/never-fetched".into(), + }) + }) + .unwrap() + .clone(); + let tag = catalog + .entries + .iter() + .find(|r| { + r.target + == (RevisionTarget::Remote { + remote: "origin".into(), + reference: "refs/tags/ancestor-tag".into(), + }) + }) + .unwrap() + .clone(); + assert!( + catalog + .entries + .iter() + .any(|r| r.target == RevisionTarget::FetchRemote("origin".into())) + ); + assert!( + !catalog + .entries + .iter() + .any(|r| r.target == RevisionTarget::Commit(ancestor.clone())) + ); + assert!( + !command("git", &active, &["cat-file", "-e", &tip]) + .status + .success() + ); + let comparison = repository + .compare(&revision(&local, RepoKind::Git), &remote) + .unwrap(); + 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(); + assert_eq!(changed(&comparison, Path::new("file")).before, ["ancestor"]); + let comparison = repository + .compare(&remote, &working(RepoKind::Git)) + .unwrap(); + assert_eq!(changed(&comparison, Path::new("file")).after, ["local"]); + let loaded = repository.fetch_remote("origin").unwrap(); + let historic = loaded + .entries + .iter() + .find(|r| { + r.target == RevisionTarget::Commit(ancestor.clone()) && r.category == Category::Commits + }) + .unwrap(); + assert_eq!(historic.label, ancestor); + assert_eq!(historic.description, "remote ancestor needle"); + let comparison = repository + .compare(historic, &revision(&local, RepoKind::Git)) + .unwrap(); + assert_eq!(changed(&comparison, Path::new("file")).before, ["ancestor"]); + assert!( + !command("git", &active, &["cat-file", "-e", &tip]) + .status + .success() + ); + assert_eq!(disk_state(&active), state); +} + +#[test] +fn jj_catalog_and_working_comparisons_prefer_jj_and_never_snapshot_or_merge_operations() { + for colocate in [false, true] { + let fixture = Fixture::new(); + let active = fixture.dir("active"); + init_jj(&active, colocate); + fs::write(active.join("file"), "baseline\n").unwrap(); + fs::write(active.join("deleted"), "delete\n").unwrap(); + fs::write(active.join(".gitignore"), "ignored-*\n").unwrap(); + jj(&active, &["describe", "-m", "jj baseline searchable"]); + let id = jj( + &active, + &[ + "--ignore-working-copy", + "--at-operation=@", + "log", + "--no-graph", + "-r", + "@", + "-T", + "commit_id", + ], + ); + let change = jj( + &active, + &[ + "--ignore-working-copy", + "--at-operation=@", + "log", + "--no-graph", + "-r", + "@", + "-T", + "change_id", + ], + ); + jj(&active, &["bookmark", "create", "baseline"]); + jj(&active, &["new"]); + fs::write(active.join("file"), "disk\n").unwrap(); + fs::remove_file(active.join("deleted")).unwrap(); + fs::write(active.join("addition"), "new\n").unwrap(); + fs::write(active.join("ignored-untracked"), "ignored\n").unwrap(); + let state = disk_state(&active); + let mut repository = open(&active); + let catalog = repository.catalog().unwrap(); + assert_eq!(catalog.kind, RepoKind::Jujutsu); + let baseline = catalog + .entries + .iter() + .find(|r| { + r.target == RevisionTarget::Commit(id.clone()) && r.category == Category::Revisions + }) + .unwrap(); + assert!(baseline.label.contains(&id) && baseline.label.contains(&change)); + assert_eq!(baseline.description, "jj baseline searchable"); + assert!( + catalog + .entries + .iter() + .any(|r| r.label == "baseline" && r.category == Category::Bookmarks) + ); + let comparison = repository + .compare(baseline, &working(RepoKind::Jujutsu)) + .unwrap(); + assert_eq!(changed(&comparison, Path::new("file")).after, ["disk"]); + assert_eq!( + changed(&comparison, Path::new("deleted")).status, + FileStatus::Removed + ); + assert_eq!( + changed(&comparison, Path::new("addition")).status, + FileStatus::Added + ); + assert!( + !comparison + .files + .iter() + .any(|f| f.path == Path::new("ignored-untracked")) + ); + assert_eq!(disk_state(&active), state); + } +} + +#[test] +fn jj_private_fetch_discovers_never_fetched_bookmark_and_searchable_remote_change_history() { + let fixture = Fixture::new(); + let source = fixture.dir("source"); + init_jj(&source, false); + fs::write(source.join("file"), "remote ancestor\n").unwrap(); + jj(&source, &["describe", "-m", "remote historic needle"]); + let ancestor = jj( + &source, + &[ + "--ignore-working-copy", + "log", + "--no-graph", + "-r", + "@", + "-T", + "commit_id", + ], + ); + jj(&source, &["new"]); + fs::write(source.join("file"), "remote tip\n").unwrap(); + jj(&source, &["describe", "-m", "remote tip"]); + let tip = jj( + &source, + &[ + "--ignore-working-copy", + "log", + "--no-graph", + "-r", + "@", + "-T", + "commit_id", + ], + ); + jj(&source, &["bookmark", "create", "never-fetched"]); + let bare = fixture.dir("remote.git"); + jj(&source, &["git", "export"]); + // Build remote fixture data by copying exported refs, never by pushing. + git( + &fixture.0, + &[ + "clone", + "--bare", + "--no-hardlinks", + source.join(".jj/repo/store/git").to_str().unwrap(), + bare.to_str().unwrap(), + ], + ); + // Git transport may derive imported change IDs; compare with an independent + // CLI import rather than assuming the exporting store's private metadata. + let oracle = fixture.dir("oracle"); + init_jj(&oracle, false); + jj( + &oracle, + &["git", "remote", "add", "origin", bare.to_str().unwrap()], + ); + jj(&oracle, &["git", "fetch", "--remote", "origin"]); + let change = jj( + &oracle, + &[ + "--ignore-working-copy", + "log", + "--no-graph", + "-r", + &ancestor, + "-T", + "change_id", + ], + ); + let active = fixture.dir("active"); + init_jj(&active, false); + fs::write(active.join("file"), "local\n").unwrap(); + jj(&active, &["describe", "-m", "local"]); + jj(&active, &["new"]); + jj( + &active, + &["git", "remote", "add", "origin", bare.to_str().unwrap()], + ); + fs::write(active.join("unsnapshotted"), "keep disk\n").unwrap(); + let state = disk_state(&active); + let mut repository = open(&active); + let catalog = repository.catalog().unwrap(); + let remote = catalog + .entries + .iter() + .find(|r| { + r.target + == (RevisionTarget::Remote { + remote: "origin".into(), + reference: "refs/heads/never-fetched".into(), + }) + }) + .unwrap() + .clone(); + assert!( + !catalog + .entries + .iter() + .any(|r| r.target == RevisionTarget::Commit(ancestor.clone())) + ); + let comparison = repository + .compare(&remote, &working(RepoKind::Jujutsu)) + .unwrap(); + assert_eq!( + changed(&comparison, Path::new("file")).before, + ["remote tip"] + ); + assert_eq!(changed(&comparison, Path::new("file")).after, ["local"]); + let loaded = repository.fetch_remote("origin").unwrap(); + let historic = loaded + .entries + .iter() + .find(|r| { + r.target == RevisionTarget::Commit(ancestor.clone()) + && r.category == Category::Revisions + }) + .unwrap(); + assert!(historic.label.contains(&ancestor) && historic.label.contains(&change)); + assert_eq!(historic.description, "remote historic needle"); + let comparison = repository.compare(historic, &remote).unwrap(); + assert_eq!( + changed(&comparison, Path::new("file")).before, + ["remote ancestor"] + ); + assert_eq!( + changed(&comparison, Path::new("file")).after, + ["remote tip"] + ); + let store = active.join(".jj/repo/store"); + let active_store = + fs::canonicalize(store.join(fs::read_to_string(store.join("git_target")).unwrap().trim())) + .unwrap(); + assert!( + !command( + "git", + &active, + &[ + "--git-dir", + active_store.to_str().unwrap(), + "cat-file", + "-e", + &tip + ] + ) + .status + .success() + ); + assert_eq!(disk_state(&active), state); +} + +#[test] +fn errors_are_explicit_and_do_not_modify_active_repository() { + let fixture = Fixture::new(); + assert!(Repository::open(&fixture.0, Arc::new(AtomicBool::new(false))).is_err()); + let root = fixture.dir("active"); + init_git(&root); + fs::write(root.join("file"), "baseline\n").unwrap(); + git(&root, &["add", "."]); + git(&root, &["commit", "-m", "baseline"]); + let id = git(&root, &["rev-parse", "HEAD"]); + git( + &root, + &[ + "remote", + "add", + "broken", + fixture.0.join("missing.git").to_str().unwrap(), + ], + ); + let state = disk_state(&root); + let mut repository = open(&root); + let catalog = repository.catalog().unwrap(); + assert!(catalog.notice.unwrap().contains("broken")); + assert!(repository.fetch_remote("broken").is_err()); + assert!( + repository + .fetch_remote("unknown") + .unwrap_err() + .contains("Unknown configured remote") + ); + assert!( + repository + .compare(&revision("--bad", RepoKind::Git), &working(RepoKind::Git)) + .unwrap_err() + .contains("Invalid immutable commit") + ); + assert!( + repository + .compare( + &revision(&"f".repeat(40), RepoKind::Git), + &working(RepoKind::Git) + ) + .unwrap_err() + .contains("unavailable") + ); + let loader = Revision { + target: RevisionTarget::FetchRemote("broken".into()), + ..working(RepoKind::Git) + }; + assert!( + repository + .compare(&loader, &revision(&id, RepoKind::Git)) + .unwrap_err() + .contains("history loader") + ); + assert!( + Repository::open(&root, Arc::new(AtomicBool::new(true))) + .err() + .unwrap() + .contains("cancelled") + ); + assert_eq!(disk_state(&root), state); +} + +#[test] +fn unchanged_submodules_are_not_false_changes_and_changed_pointers_are_explicit() { + let fixture = Fixture::new(); + let child = fixture.dir("child"); + init_git(&child); + fs::write(child.join("file"), "first\n").unwrap(); + git(&child, &["add", "."]); + git(&child, &["commit", "-m", "first"]); + let first = git(&child, &["rev-parse", "HEAD"]); + let root = fixture.dir("active"); + init_git(&root); + git( + &root, + &[ + "-c", + "protocol.file.allow=always", + "submodule", + "add", + child.to_str().unwrap(), + "nested", + ], + ); + git(&root, &["commit", "-m", "submodule"]); + let baseline = git(&root, &["rev-parse", "HEAD"]); + let mut repository = open(&root); + assert!( + repository + .compare(&revision(&baseline, RepoKind::Git), &working(RepoKind::Git)) + .unwrap() + .files + .is_empty() + ); + fs::write(child.join("file"), "second\n").unwrap(); + git(&child, &["commit", "-am", "second"]); + let second = git(&child, &["rev-parse", "HEAD"]); + git(&root.join("nested"), &["fetch", "origin"]); + git(&root.join("nested"), &["checkout", "--detach", &second]); + let state = disk_state(&root); + let comparison = repository + .compare(&revision(&baseline, RepoKind::Git), &working(RepoKind::Git)) + .unwrap(); + let submodule = changed(&comparison, Path::new("nested")); + assert_eq!(submodule.status, FileStatus::Modified); + let notice = submodule.notice.as_ref().unwrap(); + assert!( + notice.contains(&first) && notice.contains(&second) && notice.contains("not traversed") + ); + assert!(submodule.before.is_empty() && submodule.after.is_empty()); + assert!( + !comparison + .files + .iter() + .any(|f| f.path.starts_with("nested/file")) + ); + assert_eq!(disk_state(&root), state); +} + +#[test] +fn multiple_remotes_retain_tag_only_history_even_when_tag_names_collide() { + let fixture = Fixture::new(); + let mut ids = vec![]; + let mut urls = vec![]; + for name in ["one", "two"] { + let source = fixture.dir(name); + init_git(&source); + fs::write(source.join("file"), format!("{name}\n")).unwrap(); + git(&source, &["add", "."]); + git( + &source, + &["commit", "-m", &format!("{name} tag-only needle")], + ); + ids.push(git(&source, &["rev-parse", "HEAD"])); + git(&source, &["tag", "collision"]); + git(&source, &["update-ref", "-d", "refs/heads/local"]); + let bare = fixture.0.join(format!("{name}.git")); + git( + &fixture.0, + &[ + "clone", + "--bare", + source.to_str().unwrap(), + bare.to_str().unwrap(), + ], + ); + urls.push(bare); + } + let active = fixture.dir("active"); + init_git(&active); + fs::write(active.join("local"), "local\n").unwrap(); + git(&active, &["add", "."]); + git(&active, &["commit", "-m", "local"]); + for (name, url) in ["one", "two"].into_iter().zip(&urls) { + git(&active, &["remote", "add", name, url.to_str().unwrap()]); + } + let state = disk_state(&active); + let mut repository = open(&active); + repository.fetch_remote("one").unwrap(); + let loaded = repository.fetch_remote("two").unwrap(); + for (name, id) in ["one", "two"].into_iter().zip(&ids) { + assert!( + loaded + .entries + .iter() + .any(|r| r.target == RevisionTarget::Commit(id.clone()) + && r.description == format!("{name} tag-only needle")) + ); + } + let comparison = repository + .compare( + &revision(&ids[0], RepoKind::Git), + &revision(&ids[1], RepoKind::Git), + ) + .unwrap(); + assert_eq!(changed(&comparison, Path::new("file")).before, ["one"]); + assert_eq!(changed(&comparison, Path::new("file")).after, ["two"]); + assert_eq!(disk_state(&active), state); +} + +#[test] +fn jj_conflicted_revisions_show_cli_projection_and_keep_ordinary_files_navigable() { + let fixture = Fixture::new(); + let root = fixture.dir("active"); + init_jj(&root, false); + fs::write(root.join("conflict"), "base\n").unwrap(); + fs::write(root.join("ordinary"), "old\n").unwrap(); + jj(&root, &["describe", "-m", "base"]); + let base = jj( + &root, + &[ + "--ignore-working-copy", + "log", + "--no-graph", + "-r", + "@", + "-T", + "commit_id", + ], + ); + jj(&root, &["new", &base]); + fs::write(root.join("conflict"), "left\n").unwrap(); + fs::write(root.join("ordinary"), "left ordinary\n").unwrap(); + jj(&root, &["describe", "-m", "left"]); + let left = jj( + &root, + &[ + "--ignore-working-copy", + "log", + "--no-graph", + "-r", + "@", + "-T", + "commit_id", + ], + ); + jj(&root, &["new", &base]); + fs::write(root.join("conflict"), "right\n").unwrap(); + jj(&root, &["describe", "-m", "right"]); + let right = jj( + &root, + &[ + "--ignore-working-copy", + "log", + "--no-graph", + "-r", + "@", + "-T", + "commit_id", + ], + ); + jj(&root, &["new", &left, &right, "-m", "conflicted merge"]); + let merged = jj( + &root, + &[ + "--ignore-working-copy", + "--at-operation=@", + "log", + "--no-graph", + "-r", + "@", + "-T", + "commit_id", + ], + ); + let projection = command( + "jj", + &root, + &[ + "--ignore-working-copy", + "--at-operation=@", + "file", + "show", + "-r", + &merged, + "-T", + "", + "--", + "root-file:\"conflict\"", + ], + ); + assert!( + projection.status.success(), + "{}", + String::from_utf8_lossy(&projection.stderr) + ); + let projection = String::from_utf8(projection.stdout).unwrap(); + let expected: Vec<_> = projection + .strip_suffix('\n') + .unwrap_or(&projection) + .split('\n') + .map(str::to_owned) + .collect(); + let state = disk_state(&root); + let mut repository = open(&root); + let catalog = repository.catalog().unwrap(); + assert!( + catalog + .entries + .iter() + .any(|r| r.target == RevisionTarget::Commit(merged.clone())) + ); + let comparison = repository + .compare( + &revision(&base, RepoKind::Jujutsu), + &revision(&merged, RepoKind::Jujutsu), + ) + .unwrap(); + let conflict = changed(&comparison, Path::new("conflict")); + assert_eq!(conflict.after, expected); + assert!( + conflict + .notice + .as_ref() + .unwrap() + .contains("conflict projection") + ); + assert!(!conflict.hunks.is_empty()); + let ordinary = changed(&comparison, Path::new("ordinary")); + assert_eq!(ordinary.before, ["old"]); + assert_eq!(ordinary.after, ["left ordinary"]); + assert!(ordinary.notice.is_none()); + let working_comparison = repository + .compare( + &revision(&merged, RepoKind::Jujutsu), + &working(RepoKind::Jujutsu), + ) + .unwrap(); + assert!( + changed(&working_comparison, Path::new("conflict")) + .notice + .as_ref() + .unwrap() + .contains("conflict projection") + ); + assert_eq!(disk_state(&root), state); +}