diff --git a/README.md b/README.md index b6b27fb..0b93133 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ Vamp starts in Normal mode. Press `i`, type text, then Esc to return to Normal. | Command palette | Ctrl+p; type to filter, arrows select, Enter accepts, Esc cancels | | Buffer picker in Normal | Space then `b l` or `:buffers`; type to search, arrows select, Enter switches, Esc cancels | | Floating/docked explorer in Normal or Visual | Space then `o` / Space then `e` | -| Repository diff in Normal | `:diff`; choose Before then After, Tab changes category, type to search; Esc closes the selector; Working checkout After is editable; `:diff.close` exits the comparison | +| Repository diff in Normal | `:diff`; choose Before then After, Tab changes category, type to search; Esc closes the selector; Working or checkout-matched pinned After is editable; `:diff.close` exits the comparison | | Diff layout in Normal | Space then `v` cycles split/unified; Space then `s` flips left/right vs above/below split | 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/TODO.md b/TODO.md index 9e0c827..a1ddc20 100644 --- a/TODO.md +++ b/TODO.md @@ -1,4 +1,16 @@ +- [ ] noticed that `dw` also removes the newline and as such merges lines. this is not intended behavior and should be fixed. `dw` should not affect other lines (and neither should `db`). - [ ] add `cw` and `cb` actions mimicking the `d` equivalent, but also entering insert mode. +- [ ] add git gutter info: + - fat, vertical, colored bar on inner side of gutter closer to gutter than to text (default) + - colored symbols: + - new: `+` + - deleted: `-` + - changed: `~` + - colored line numbers (current line color overrides) + - colors: + - new: green + - deleted: red + - changed: yellow - [ ] github/gitlab/gitea integration (probably requries authentication with github) - github pr picker (floating window) - three tabs: open, closed, all diff --git a/docs/architecture.md b/docs/architecture.md index 0ab0fe8..8a1f1ec 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -100,7 +100,7 @@ macOS publishes `public.utf8-plain-text` through the native Pasteboard Manager, The native module initializes each server, sends `initialization_options` as `initializationOptions`, answers `workspace/configuration` from `settings` (including dotted sections; missing sections return `null`), honors open/change/save capabilities, synchronizes in-memory text including hidden dirty buffers, and sends close events. Incremental servers receive whole-document range replacements. Process/pipe I/O and requests stay on bounded background workers; input and idle diagnostic redraw do not wait for server responses. -Diff snapshots retain a separate immutable document identity, full source text, side-specific path (the old path for renamed Before), and UTF-16 positions. Native definition updates preserve their UTF-16 ranges until the editor resolves the target's actual snapshot, dirty buffer, disk text, or lazily loaded comparison file; generic plugin navigation remains grapheme-based. Target ranges overlapping actual changed rows route within the same comparison and reveal the exact selection, including an unchanged declaration above a changed body. Wholly unchanged/context/omitted targets and files outside its changes open an ordinary tab without changing the original comparison or view. Snapshot-URI targets keep their immutable identity. Freshness includes tab, pane, source buffer/revision/caret/mode, and comparison/side; deferred file-load navigation also checks its originating view before moving focus. Invalid targets fail before opening a tab. Completion remains restricted to writable Working After. +Diff snapshots retain a separate immutable document identity, full source text, side-specific path (the old path for renamed Before), and UTF-16 positions. Native definition updates preserve their UTF-16 ranges until the editor resolves the target's actual snapshot, dirty buffer, disk text, or lazily loaded comparison file; generic plugin navigation remains grapheme-based. Target ranges overlapping actual changed rows route within the same comparison and reveal the exact selection, including an unchanged declaration above a changed body. Wholly unchanged/context/omitted targets and files outside its changes open an ordinary tab without changing the original comparison or view. Snapshot-URI targets keep their immutable identity. Freshness includes tab, pane, source buffer/revision/caret/mode, and comparison/side; deferred file-load navigation also checks its originating view before moving focus. Invalid targets fail before opening a tab. Completion remains restricted to writable After, whether Working or a checkout-matched pinned endpoint. Language configurations can own an ordered server stack. The first matching language wins extension overlap; all its matching servers receive document events, while the first handles hover/definition/completion. The Python preset uses ty then Ruff. LSP merges each worker's latest diagnostic snapshot in assignment order before emitting the unchanged core-owned update. Empty publications/retirement remove only their source; revision/path/root changes and close discard all contributions. Worker snapshots retain identity so queued pre-rename diagnostics cannot attach to a new path with the same text revision. Combined diagnostic message bytes remain bounded to 4 MiB; an overflowing publication retires its worker instead of discarding healthy sibling annotations. Workers are stored in config/root order, avoiding per-poll key cloning/sorting. Shutdown flushes the exit notification and waits for peer termination within the existing bounded grace period before process cleanup. diff --git a/docs/configuration.md b/docs/configuration.md index 10beb1e..d2899c9 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -52,7 +52,7 @@ Plugin paths resolve relative to the config file, not the shell's working direct `config.diff_view_mode` selects the initial view for each new comparison: `"split"` (default) or `"unified"`. `config.diff_split_orientation` selects `"vertical"` (left/right, default) or `"horizontal"` (above/below) for split views. Invalid values reject the configuration. Normal-mode **Space v** cycles split/unified; **Space s** flips split orientation. These controls affect the current comparison, not startup defaults, and retain their choices across file switches and tabs. Orientation changes apply only in split view. -`config.diff_follow_working_copy` defaults to `true`. A Working-checkout After follows descendant checkouts automatically, including in inactive tabs; `false` disables endpoint following without disabling ordinary live-buffer editing or reload. Only literal booleans are accepted. Historical After endpoints never follow. See [repository diff](editing.md#repository-diff) for ancestry and refresh behavior. +`config.diff_follow_working_copy` defaults to `true`. A Working-checkout After follows descendant checkouts automatically, including in inactive tabs; `false` disables endpoint following without disabling ordinary live-buffer editing or reload. Only literal booleans are accepted. Pinned After endpoints never move, but their checkout-aware editability checks remain enabled regardless of this setting. See [repository diff](editing.md#repository-diff) for ancestry and refresh behavior. ```ruby config.bind "ctrl+b", config.core.diff.mode.toggle @@ -252,7 +252,7 @@ config.bind "ctrl+f", config.core.right, mode: config.modes.tree config.bind "ctrl+h", nil, mode: config.modes.tree ``` -The diff's changed-file explorer and floating picker use these same tree and selection contexts, including remaps and disabled keys. Enter opens the selected file's comparison; Working checkout After reuses its editable buffer. The catalog contains only changed paths, with A/M/D/R status markers, rather than a disk scan. +The diff's changed-file explorer and floating picker use these same tree and selection contexts, including remaps and disabled keys. Enter opens the selected file's comparison; writable After reuses its live buffer. The catalog contains only changed paths, with A/M/D/R status markers, rather than a disk scan. Ordinary `config.core.file_picker` and `config.core.file_explorer` bindings work inside comparisons. Ctrl+w closes a focused picker/explorer, retaining its query, selection, and expansion state; the panes use the reclaimed width. Ctrl+e has no reserved diff behavior. Escape closes the revision selector in either stage, but an opened comparison still closes explicitly through `:diff.close`, its palette action, or the generated action value below. `config.core.diff` remains the opening action and exposes immutable `close`, `mode.toggle`, `view.toggle`, and `orientation.toggle` children. New comparisons default to Branch changes (merge base → selected After); Normal-mode Ctrl+b, `:diff.mode.toggle`, or the palette toggles to direct Snapshots and back, preserving the selected endpoints. Selecting Working checkout on either side forces Snapshots. diff --git a/docs/editing.md b/docs/editing.md index bf5936b..7dc0280 100644 --- a/docs/editing.md +++ b/docs/editing.md @@ -357,21 +357,23 @@ Comparison discovery builds the changed-path/status catalog first. File text, no **Working checkout After follows forward checkouts automatically**, checked asynchronously once per second, including inactive tabs. A descendant jj change or Git HEAD rebuilds the changed-file catalog and captures current working bytes plus eligible unsaved buffers, while keeping the original Before frozen. jj tracking uses change IDs, so snapshot/description rewrites of the same change do not advance the endpoint or prevent following its later descendants. Same, backward, and unrelated checkouts do not rebuild the catalog; live-buffer editing and external reload still work normally. Historical After never follows. Set `config.diff_follow_working_copy = false` to disable following. -Refresh retains the selected path when still present, explorer query/view/visibility, logical text-side focus, and split/unified/orientation/wrap choices; it clears review marks for the new endpoint. Removed selections fall back to the first changed file. Failed refreshes keep the previous comparison visible. Superseded file loads and LSP navigation cannot apply to the new comparison; snapshots still displayed in other panes or tabs remain read-only. +**Pinned After editability tracks the checkout without moving either endpoint**, checked asynchronously once per second, including inactive tabs and when `config.diff_follow_working_copy = false`. Git matches the selected commit to HEAD; jj matches its change ID so snapshot/description rewrites of the same change remain editable. A matching After uses the shared live buffer. Switching away restores the original read-only After snapshot; switching back reuses the live buffer's unsaved edits and undo history. A failed checkout check revokes pinned After editing until a successful check restores eligibility. -The changed-file explorer is **docked on the right**. In the default split layout, modified files show Before on the left and After on the right. Added files show only After, using the full text workspace; deleted files show only Before. Rename-only files show one empty pane, with the old and new paths in the explorer; renames with text or final-newline changes retain both source sides. Before and historical/remote After snapshots are read-only. Selecting **Working checkout** as After binds eligible regular UTF-8 files to normal editor buffers, reusing already-open dirty buffers and their undo history. Editable After supports native edits, completion, undo/redo, and `:w`. Removed files, rename-only files, binary files, symlinks, and submodules remain read-only. Closing the diff keeps its buffers and unsaved edits. +Refresh retains the selected path when still present, explorer query/view/visibility, logical text-side focus, and split/unified/orientation/wrap choices; moving the Working endpoint clears review marks, while pinned checkout-only transitions retain them. Removed selections fall back to the first changed file. Failed refreshes keep the previous comparison visible. Superseded file loads and LSP navigation cannot apply to the new comparison; snapshots still displayed in other panes or tabs remain read-only. + +The changed-file explorer is **docked on the right**. In the default split layout, modified files show Before on the left and After on the right. Added files show only After, using the full text workspace; deleted files show only Before. Rename-only files show one empty pane, with the old and new paths in the explorer; renames with text or final-newline changes retain both source sides. Before and unmatched pinned After snapshots are read-only. **Working checkout** or a checkout-matched pinned After binds eligible regular UTF-8 files to normal editor buffers, reusing already-open dirty buffers and their undo history. Editable After supports native edits, completion, undo/redo, and `:w`. Removed files, rename-only files, binary files, symlinks, and submodules remain read-only. Closing the diff keeps its buffers and unsaved edits. Comparisons start in **split, vertical (left/right)** view by default. In Normal mode, **Space v** cycles split/unified; **Space s** switches a split between vertical (left/right) and horizontal (above/below, Before above After). Canonical commands are `:diff.view.toggle` and `:diff.orientation.toggle`; both are available in the palette and as remappable `config.core.diff.view.toggle` / `config.core.diff.orientation.toggle` actions. Configure new-comparison defaults with `config.diff_view_mode = "split"` or `"unified"` and `config.diff_split_orientation = "vertical"` or `"horizontal"`. Current choices survive file and tab switches; closing and reopening uses the configured defaults. Orientation stays unchanged while unified is active. -**Unified** uses one full-width text pane with both original line-number columns: context appears once, then each changed run shows removed (`-`) lines followed by added (`+`) lines, without alignment holes. Tab/Shift+Tab still switches logical Before/After focus, preserving native source coordinates, read-only protection, and Working After editing. The border shows the combined revisions and the focused side's editability. Word wrap, syntax/semantic and character highlighting, search, selections, excerpt expansion, and wheel scrolling work in either view. +**Unified** uses one full-width text pane with both original line-number columns: context appears once, then each changed run shows removed (`-`) lines followed by added (`+`) lines, without alignment holes. Tab/Shift+Tab still switches logical Before/After focus, preserving native source coordinates, read-only protection, and writable After editing. The border shows the combined revisions and the focused side's editability. Word wrap, syntax/semantic and character highlighting, search, selections, excerpt expansion, and wheel scrolling work in either view. -Diff metadata lives in the text-pane borders instead of a separate info row: split panes show their own Before/After revision labels at the bottom-left; a single visible pane shows the combined `before → after` label there. Each pane shows its read-only/editable state at the top-right, independently of keyboard focus. Before is always read-only; After is editable only for an eligible Working-checkout file. The changed-file explorer and panes start directly below configured chrome, and there is no native-buffer help footer. Loading/error/file notices remain visible when applicable. Narrow borders prioritize state and clip titles without painting over the gutter or source text. +Diff metadata lives in the text-pane borders instead of a separate info row: split panes show their own Before/After revision labels at the bottom-left; a single visible pane shows the combined `before → after` label there. Each pane shows its read-only/editable state at the top-right, independently of keyboard focus. Before is always read-only; After is editable only for an eligible Working-checkout or checkout-matched pinned file. The changed-file explorer and panes start directly below configured chrome, and there is no native-buffer help footer. Loading/error/file notices remain visible when applicable. Narrow borders prioritize state and clip titles without painting over the gutter or source text. For jj-managed repositories, revision entries and border labels display only the change ID; Git repositories keep commit-ID labels, and named bookmarks retain their names. The backing commit ID remains the exact comparison target and can still be used to filter revisions. Text panes use normal buffer interaction regardless of writability: motions and counts, search, Visual/Visual Line selections, multiple carets, yank, remapped bindings, and non-mutating actions work in Before and After. Read-only snapshots reject insert/delete/change/paste, repeat edits, undo/redo, mutating plugin effects, and saves, including saves to another path. Their text retains the revision's final newline. Snapshots use separate virtual LSP document identities and do not appear in ordinary buffer lists or dirty-buffer comparisons. Closing the diff disposes unreferenced snapshots and restores the live or original buffer in its workspace pane; snapshots displayed by other panes or tabs remain read-only until closed. Explicit buffer commands and Markdown/plugin navigation retain their ordinary behavior; native LSP definitions use the routing below. -**LSP definition, hover, and diagnostics work normally in focused Before and After text panes**, including historical snapshots. Each snapshot is served with its immutable revision text and its own document URI, not the current disk contents. Completion and completion edits are available only in writable Working-checkout After; they use the shared buffer's normal undo history. Revision selectors and the changed-file explorer are not document-action targets. +**LSP definition, hover, and diagnostics work normally in focused Before and After text panes**, including historical snapshots. Each snapshot is served with its immutable revision text and its own document URI, not the current disk contents. Completion and completion edits are available only in writable After; they use the shared buffer's normal undo history. Revision selectors and the changed-file explorer are not document-action targets. Accepted LSP definitions center the selection's caret row once in the destination pane, including wrapped visual rows and revealed diff excerpts. Centering uses the focused pane's actual height in either split orientation or unified view. The next motion does not recenter; failed or superseded definitions preserve the current logical viewport. @@ -383,7 +385,7 @@ File rows show colored **A** (added, green), **M** (modified, yellow), **D** (re Mark or unmark a diff file with **`c` while browsing the changed-file tree**, **`:diff.review.toggle`**, or the command-palette action. With the changed-file explorer focused, it toggles the highlighted file without opening it; from a text pane, the command toggles the displayed file. Reviewed files show a green **`✓` immediately before A/M/D/R** in Tree and List views. Directories and ordinary filesystem pickers cannot be marked. While filtering, including List view, `c` remains query text; use the palette or a control-key binding there. Explicit bindings take precedence over the default browsing shortcut. -Review marks belong only to the open comparison and survive filtering, file navigation, lazy loading, tabs, and split/unified/orientation changes. A new or recomputed comparison clears them. Editing a Working After file clears only that file's mark; unchanged refreshes and saves preserve it. Marks do not change document text, undo history, or repository state, and are not persisted after closing the comparison. +Review marks belong only to the open comparison and survive filtering, file navigation, lazy loading, tabs, split/unified/orientation changes, and pinned checkout-only transitions. A new or recomputed comparison clears them. Editing a writable After file clears only that file's mark; unchanged refreshes and saves preserve it. Marks do not change document text, undo history, or repository state, and are not persisted after closing the comparison. Text panes show change excerpts with **10 unchanged lines above and below** each change, preserving original file line numbers. Overlapping or touching excerpts merge; omitted stretches show an unchanged-line count in both panes. Files with only metadata changes show their notice or “No text changes” rather than unrelated source. diff --git a/src/diff.rs b/src/diff.rs index 2645a13..3b610d8 100644 --- a/src/diff.rs +++ b/src/diff.rs @@ -1,7 +1,7 @@ use crate::editor::{BackspaceKind, Input}; use crate::files::{FilePicker, FilePresentation, FileView}; use std::{ - collections::BTreeSet, + collections::{BTreeMap, BTreeSet}, path::{Path, PathBuf}, }; use unicode_segmentation::UnicodeSegmentation; @@ -108,7 +108,7 @@ pub struct Comparison { pub after: String, pub files: Vec, pub root: PathBuf, - /// Regular working-After candidates; loaded binary details are excluded. + /// Eligible regular-file After paths; loaded binary details are excluded. pub editable_paths: BTreeSet, } #[derive(Debug)] @@ -122,8 +122,9 @@ pub enum Operation { mode: ComparisonMode, buffers: Box<[(PathBuf, String)]>, }, - RefreshWorking { + RefreshCheckout { comparison_id: u64, + follow_working: bool, buffers: Box<[(PathBuf, String)]>, }, LoadFile { @@ -135,7 +136,7 @@ pub enum Operation { pub enum Outcome { Catalog(Catalog), Comparison(Comparison), - WorkingRefresh(Option), + CheckoutRefresh(Option), File { comparison_id: u64, file_index: usize, @@ -195,7 +196,7 @@ pub enum DisplayRow { enum RequestKind { Catalog, Comparison, - WorkingRefresh { + CheckoutRefresh { comparison_id: u64, }, File { @@ -238,6 +239,7 @@ pub struct DiffState { pub live_revision: Option, pub follow_caret: bool, live_files: BTreeSet, + frozen_after: BTreeMap, bool)>, context_lines: usize, expand_lines: usize, pending: Option, @@ -285,6 +287,7 @@ impl DiffState { live_revision: None, follow_caret: false, live_files: BTreeSet::new(), + frozen_after: BTreeMap::new(), context_lines: 10, expand_lines: 15, pending: Some(Operation::Discover), @@ -317,31 +320,32 @@ impl DiffState { pub(crate) fn request_ready(&self) -> bool { self.pending.as_ref().is_some_and(|operation| { - !self.refresh_pending() || matches!(operation, Operation::RefreshWorking { .. }) + !self.refresh_pending() || matches!(operation, Operation::RefreshCheckout { .. }) }) } pub(crate) fn refresh_pending(&self) -> bool { - matches!(self.request_kind, Some(RequestKind::WorkingRefresh { .. })) + matches!(self.request_kind, Some(RequestKind::CheckoutRefresh { .. })) } - pub(crate) fn queue_working_refresh(&mut self) { + pub(crate) fn queue_checkout_refresh(&mut self, follow_working: bool) { if self.phase != Phase::View || self.loading || self.request_kind.is_some() || self.pending.is_some() || self.comparison.is_none() - || !self + || self .after .as_ref() - .is_some_and(|after| after.target == RevisionTarget::Working) + .is_none_or(|after| after.target == RevisionTarget::Working && !follow_working) { return; } if let Some(comparison_id) = self.comparison_id { - self.request_kind = Some(RequestKind::WorkingRefresh { comparison_id }); - self.pending = Some(Operation::RefreshWorking { + self.request_kind = Some(RequestKind::CheckoutRefresh { comparison_id }); + self.pending = Some(Operation::RefreshCheckout { comparison_id, + follow_working, buffers: Box::new([]), }); } @@ -349,7 +353,7 @@ impl DiffState { fn queue(&mut self, operation: Operation) { if self.refresh_pending() - && !matches!(self.pending, Some(Operation::RefreshWorking { .. })) + && !matches!(self.pending, Some(Operation::RefreshCheckout { .. })) && matches!(operation, Operation::LoadFile { .. }) { // Finish the refresh before issuing a load against its frozen comparison ID. @@ -378,20 +382,28 @@ impl DiffState { if self.request_kind.is_none() { return false; } - if let Some(RequestKind::WorkingRefresh { comparison_id }) = self.request_kind { + if let Some(RequestKind::CheckoutRefresh { comparison_id }) = self.request_kind { if self.comparison_id != Some(comparison_id) { return false; } self.request_kind = None; let deferred = self.pending.take(); match result { - Ok(Outcome::WorkingRefresh(None)) => { + Ok(Outcome::CheckoutRefresh(None)) => { if let Some(operation) = deferred { self.queue(operation); } return false; } - Ok(Outcome::WorkingRefresh(Some(comparison))) => { + Ok(Outcome::CheckoutRefresh(Some(mut comparison))) => { + let pinned = self + .after + .as_ref() + .is_some_and(|after| after.target != RevisionTarget::Working); + let view = (self.top, self.left_before, self.left_after); + if !pinned { + self.reviewed_files.clear(); + } let selected = self .comparison .as_ref() @@ -402,6 +414,19 @@ impl DiffState { comparison.files.iter().position(|file| &file.path == path) }) .unwrap_or(0); + if pinned { + self.restore_frozen_after(); + if let Some(old) = self.comparison.take() { + // Checkout-only refreshes retain the frozen catalog order. + for (file, replacement) in + old.files.into_iter().zip(&mut comparison.files) + { + if file.loaded { + *replacement = file; + } + } + } + } if let Some(files) = &mut self.files { files.replace_paths( comparison @@ -414,25 +439,34 @@ impl DiffState { self.comparison = Some(comparison); self.comparison_id = Some(id); self.snapshot_roots = [None, None]; - self.reviewed_files.clear(); + self.frozen_after.clear(); self.live_files.clear(); self.live_buffer = None; self.live_revision = None; self.follow_caret = false; self.prepare_selected_file(); + if pinned { + self.top = view.0.min(self.display_rows.len().saturating_sub(1)); + self.left_before = view.1.min(self.max_before); + self.left_after = view.2.min(self.max_after); + } self.normalize_focus(); } Err(error) => { + self.revoke_pinned_after(); if let Some(operation) = deferred { self.queue(operation); } self.error = Some(error); } - Ok(_) => self.error = Some("Unexpected repository response".into()), + Ok(_) => { + self.revoke_pinned_after(); + self.error = Some("Unexpected repository response".into()); + } } return true; } - if matches!(result, Ok(Outcome::WorkingRefresh(_))) { + if matches!(result, Ok(Outcome::CheckoutRefresh(_))) { return false; } if let Ok(Outcome::File { @@ -483,6 +517,7 @@ impl DiffState { } Ok(Outcome::Comparison(comparison)) if expected == Some(RequestKind::Comparison) => { self.live_files.clear(); + self.frozen_after.clear(); self.reviewed_files.clear(); self.live_buffer = None; self.live_revision = None; @@ -972,18 +1007,48 @@ impl DiffState { }; } - pub fn after_editable(&self) -> bool { - matches!(self.pane_layout(), PaneLayout::Split | PaneLayout::After) - && self.working_after_available() + fn revoke_pinned_after(&mut self) { + if self + .after + .as_ref() + .is_none_or(|after| after.target == RevisionTarget::Working) + { + return; + } + if let Some(comparison) = &mut self.comparison { + comparison.editable_paths.clear(); + } + self.restore_frozen_after(); + self.live_files.clear(); + self.live_buffer = None; + self.live_revision = None; + self.follow_caret = false; + self.rebuild_file(false); + self.refresh_files(); } - pub(crate) fn working_after_available(&self) -> bool { + fn restore_frozen_after(&mut self) { + if let Some(comparison) = &mut self.comparison { + for (index, (after, eof)) in std::mem::take(&mut self.frozen_after) { + if let Some(file) = comparison.files.get_mut(index) { + file.after = after; + file.after_eof = eof; + file.hunks = + line_hunks(&file.before, file.before_eof, &file.after, file.after_eof); + update_eof_notice(file); + } + } + } + } + + pub fn after_editable(&self) -> bool { + matches!(self.pane_layout(), PaneLayout::Split | PaneLayout::After) + && self.live_after_available() + } + + pub(crate) fn live_after_available(&self) -> bool { self.phase == Phase::View && self.selected_file_loaded() - && self - .after - .as_ref() - .is_some_and(|revision| revision.target == RevisionTarget::Working) && self.comparison.as_ref().is_some_and(|comparison| { comparison.files.get(self.file).is_some_and(|file| { comparison.editable_paths.contains(&file.path) @@ -1302,13 +1367,22 @@ impl DiffState { } pub fn refresh_after(&mut self, lines: &[String], revision: u64) { - if !self.working_after_available() { + if !self.live_after_available() { return; } if self.live_revision == Some(revision) { return; } let file = &self.comparison.as_ref().unwrap().files[self.file]; + if self + .after + .as_ref() + .is_some_and(|after| after.target != RevisionTarget::Working) + { + self.frozen_after + .entry(self.file) + .or_insert_with(|| (file.after.clone(), file.after_eof)); + } let was_live = self.live_files.contains(&self.file); let changed_lines = changed_line_range(&file.after, lines); if self.reviewed_files.contains(&self.file) { @@ -3297,19 +3371,19 @@ mod tests { files.start_search(); files.query = "file".into(); files.refilter(); - let original = format!("{state:?}"); - state.queue_working_refresh(); + state.queue_checkout_refresh(true); assert!(matches!( state.take_request(), - Some(Operation::RefreshWorking { + Some(Operation::RefreshCheckout { comparison_id: 7, .. }) )); - assert!(!state.apply_result(8, Ok(Outcome::WorkingRefresh(None)))); - assert_eq!(format!("{state:?}"), original); + assert!(!state.apply_result(8, Ok(Outcome::CheckoutRefresh(None)))); + assert_eq!(state.comparison_id, Some(7)); + assert_eq!(state.reviewed_files, BTreeSet::from([0])); - state.queue_working_refresh(); + state.queue_checkout_refresh(true); state.take_request(); assert!(state.apply_result(9, Err("refresh failed".into()))); assert_eq!(state.comparison_id, Some(7)); @@ -3317,14 +3391,14 @@ mod tests { assert_eq!(state.reviewed_files, BTreeSet::from([0])); assert_eq!(state.error.as_deref(), Some("refresh failed")); - state.queue_working_refresh(); + state.queue_checkout_refresh(true); state.take_request(); let mut replacement = live_state(vec!["old".into()], vec!["ahead".into()], false) .comparison .take() .unwrap(); replacement.files.insert(0, comparison().files.remove(0)); - assert!(state.apply_result(10, Ok(Outcome::WorkingRefresh(Some(replacement))))); + assert!(state.apply_result(10, Ok(Outcome::CheckoutRefresh(Some(replacement))))); assert_eq!(state.comparison_id, Some(10)); assert_eq!(state.file, 1); assert_eq!(state.focus, Focus::After); @@ -3341,17 +3415,55 @@ mod tests { files.entries().nth(files.selected).unwrap().path, Path::new("file") ); - let refreshed = format!("{state:?}"); - assert!(!state.apply_result(7, Ok(Outcome::WorkingRefresh(None)))); - assert_eq!(format!("{state:?}"), refreshed); + assert!(!state.apply_result(7, Ok(Outcome::CheckoutRefresh(None)))); + assert_eq!(state.comparison_id, Some(10)); + assert_eq!(state.file, 1); state.after.as_mut().unwrap().target = RevisionTarget::Commit("historical".into()); - state.queue_working_refresh(); - assert!(!state.request_queued()); + state.queue_checkout_refresh(false); + assert!(state.request_queued()); + assert!(matches!( + state.take_request(), + Some(Operation::RefreshCheckout { + follow_working: false, + .. + }) + )); + assert!(!state.apply_result(11, Ok(Outcome::CheckoutRefresh(None)))); + state.reviewed_files.insert(1); + state.queue_checkout_refresh(false); + state.take_request(); + let mut demoted = live_state(vec!["old".into()], vec!["ahead".into()], false) + .comparison + .take() + .unwrap(); + demoted.files.insert(0, comparison().files.remove(0)); + demoted.editable_paths.clear(); + assert!(state.apply_result(12, Ok(Outcome::CheckoutRefresh(Some(demoted))))); + assert_eq!(state.reviewed_files, BTreeSet::from([1])); + assert!(!state.live_after_available()); + state + .comparison + .as_mut() + .unwrap() + .editable_paths + .insert("file".into()); + state.refresh_after(&["dirty".into()], 1); + assert_eq!(state.comparison.as_ref().unwrap().files[1].after, ["dirty"]); + state.queue_checkout_refresh(false); + state.take_request(); + assert!(state.apply_result(13, Err("checkout unavailable".into()))); + assert!(!state.live_after_available()); + assert_eq!(state.comparison_id, Some(12)); + assert_eq!(state.comparison.as_ref().unwrap().files[1].after, ["ahead"]); state.after.as_mut().unwrap().target = RevisionTarget::Working; + state.queue_checkout_refresh(false); + assert!(!state.request_queued()); state.load_file(0); - let user_request = format!("{state:?}"); - state.queue_working_refresh(); - assert_eq!(format!("{state:?}"), user_request); + state.queue_checkout_refresh(true); + assert!(matches!( + state.pending, + Some(Operation::LoadFile { file_index: 0, .. }) + )); } #[test] @@ -3807,6 +3919,7 @@ mod tests { assert_eq!(empty.rows[0].kind, RowKind::Equal); assert_eq!(empty.after_display_row(0), Some(0)); empty.after.as_mut().unwrap().target = RevisionTarget::Commit("historic".into()); + empty.comparison.as_mut().unwrap().editable_paths.clear(); empty.refresh_after(&["must not apply".into()], 2); assert_eq!(empty.comparison.as_ref().unwrap().files[0].after, [""]); empty.handle(Input::Escape); diff --git a/src/editor.rs b/src/editor.rs index 0a82ddf..8fd3a7b 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -2588,7 +2588,7 @@ impl Editor { self.diff_display_options = (view_mode, split_axis); } - pub(crate) fn queue_working_refreshes(&mut self) { + pub(crate) fn queue_checkout_refreshes(&mut self, follow_working: bool) { for index in 0..self.tab_order.len() { let tab = self.tab_order[index]; let owner = if tab == self.tab_id { @@ -2599,7 +2599,7 @@ impl Editor { if let Some(owner) = owner { self.with_pane(owner, |editor| { if let Some(diff) = &mut editor.diff { - diff.queue_working_refresh(); + diff.queue_checkout_refresh(follow_working); } }); } @@ -2638,7 +2638,19 @@ impl Editor { { Some(buffers) } - Operation::RefreshWorking { buffers, .. } => Some(buffers), + Operation::RefreshCheckout { + follow_working: true, + buffers, + .. + } if self + .diff + .as_ref() + .or(self.parked_diff.as_ref()) + .and_then(|diff| diff.after.as_ref()) + .is_some_and(|after| after.target == RevisionTarget::Working) => + { + Some(buffers) + } _ => None, }; if let Some(buffers) = buffers { @@ -2694,6 +2706,25 @@ impl Editor { return self.with_pane(owner, |editor| editor.apply_diff_result(id, result)); } self.outstanding_diff_request = None; + if self.diff.as_ref().is_some_and(DiffState::refresh_pending) + && !matches!(&result, Ok(Outcome::CheckoutRefresh(None))) + { + self.finish_completion(); + self.snippet = None; + self.clear_hover(); + if self.active.view.mode == Mode::Insert { + self.finish_insert(); + self.leave_insert(); + } + } + let preserved_view = (self.diff.as_ref().is_some_and(|diff| { + diff.refresh_pending() + && diff + .after + .as_ref() + .is_some_and(|after| after.target != RevisionTarget::Working) + }) && !matches!(&result, Ok(Outcome::CheckoutRefresh(None)))) + .then(|| self.active.view.inherit()); let Some(diff) = &mut self.diff else { return false; }; @@ -2701,15 +2732,10 @@ impl Editor { return false; } let refresh = diff.refresh_pending(); - let previous = diff.comparison_id; if !diff.apply_result(id, result) { return false; } - let replaced = previous != diff.comparison_id; if refresh { - if !replaced { - return true; - } self.pending_lsp_navigation = None; self.diff_bound = None; } else if let Some(files) = &mut diff.files { @@ -2719,7 +2745,34 @@ impl Editor { self.diff.as_ref().and_then(|diff| diff.comparison_id) != Some(role.0) }); self.bind_diff_buffer(); + let pinned_refresh = preserved_view.is_some(); + if self.active.diff_snapshot.is_some() + && let Some(mut view) = preserved_view + { + view.clamp(&self.active.lines); + self.active.view = view; + } + let reviewed = self.diff.as_mut().and_then(|diff| { + (refresh + && diff + .after + .as_ref() + .is_some_and(|after| after.target != RevisionTarget::Working)) + .then(|| std::mem::take(&mut diff.reviewed_files)) + }); self.refresh_live_diff(); + if let Some(reviewed) = reviewed { + self.diff + .as_mut() + .expect("comparison remains open") + .reviewed_files = reviewed; + } + if pinned_refresh { + self.diff + .as_mut() + .expect("comparison remains open") + .follow_caret = false; + } if refresh || obsolete_snapshot { self.prune_diff_snapshots(); } @@ -2976,8 +3029,15 @@ impl Editor { diff.focus }; let key = (comparison_id, diff.file, focus); + let writable = focus == Focus::After && diff.after_editable(); if self.diff_bound.is_some_and(|(id, file, side, buffer)| { - (id, file, side) == key && buffer == self.active.id + (id, file, side) == key + && buffer == self.active.id + && if writable { + self.active.diff_snapshot.is_none() && diff.live_buffer == Some(self.active.id) + } else { + self.active.diff_snapshot == Some(key) + } }) { return; } @@ -2996,7 +3056,6 @@ impl Editor { diff.corresponding_row(from, focus, target.active.row) .map(|row| (row, column)) }); - let writable = focus == Focus::After && diff.after_editable(); // Binding a comparison side is not a user departure from its palette. let palette = self.palette.take(); if writable { @@ -3139,7 +3198,7 @@ impl Editor { } }); if let Some(buffer) = buffer - && diff.working_after_available() + && diff.live_after_available() && diff.live_revision != Some(buffer.text_revision) { diff.refresh_after(&buffer.lines, buffer.text_revision); @@ -3833,10 +3892,6 @@ impl Editor { return Err("Definition target is binary or non-UTF-8".into()); } let writable = focus == Focus::After - && diff - .after - .as_ref() - .is_some_and(|revision| revision.target == RevisionTarget::Working) && comparison.editable_paths.contains(&file.path) && file.status != crate::diff::FileStatus::Removed && file @@ -3872,13 +3927,7 @@ impl Editor { let file = &comparison.files[file_index]; let live = if writable { Some(target) - } else if focus == Focus::Before - && diff - .after - .as_ref() - .is_some_and(|revision| revision.target == RevisionTarget::Working) - && comparison.editable_paths.contains(&file.path) - { + } else if focus == Focus::Before && comparison.editable_paths.contains(&file.path) { let identity = path_identity(&comparison.root.join(&file.path)).ok(); std::iter::once(&self.active) .chain(self.parked.values()) @@ -12780,14 +12829,14 @@ mod tests { let visible_tab = editor.tab_id(); let visible_pane = editor.pane_id(); let cursor = editor.cursor(); - editor.queue_working_refreshes(); + editor.queue_checkout_refreshes(true); let (tab, request, operation) = editor.take_next_diff_request().unwrap(); assert_eq!(tab, hidden_tab); assert!( - matches!(operation, Operation::RefreshWorking { comparison_id, .. } + matches!(operation, Operation::RefreshCheckout { comparison_id, .. } if comparison_id == old_comparison) ); - assert!(!editor.apply_diff_result(request, Ok(Outcome::WorkingRefresh(None)))); + assert!(!editor.apply_diff_result(request, Ok(Outcome::CheckoutRefresh(None)))); assert_eq!( editor.tab_diff(hidden_tab).unwrap().comparison_id, Some(old_comparison) @@ -12797,12 +12846,12 @@ mod tests { assert_eq!(editor.buffer_id(), old_snapshot); assert_eq!(editor.cursor(), cursor); - editor.queue_working_refreshes(); + editor.queue_checkout_refreshes(true); let (_, request, _) = editor.take_next_diff_request().unwrap(); let replacement = editor.with_pane(owner, |editor| { editor.diff.as_mut().unwrap().comparison.take().unwrap() }); - assert!(editor.apply_diff_result(request, Ok(Outcome::WorkingRefresh(Some(replacement))))); + assert!(editor.apply_diff_result(request, Ok(Outcome::CheckoutRefresh(Some(replacement))))); assert_eq!( editor.tab_diff(hidden_tab).unwrap().comparison_id, Some(request) @@ -12816,7 +12865,7 @@ mod tests { .apply(vec![Effect::Insert("rejected".into())]) .is_err() ); - assert!(!editor.apply_diff_result(request, Ok(Outcome::WorkingRefresh(None)))); + assert!(!editor.apply_diff_result(request, Ok(Outcome::CheckoutRefresh(None)))); editor.with_pane(owner, |editor| { assert_ne!(editor.buffer_id(), old_snapshot); @@ -12846,7 +12895,7 @@ mod tests { let mut target = lazy_lsp_target(&mut editor); let old_comparison = editor.diff().unwrap().comparison_id.unwrap(); let virtual_root = editor.diff().unwrap().snapshot_roots[0].clone().unwrap(); - editor.queue_working_refreshes(); + editor.queue_checkout_refreshes(true); let (_, refresh, _) = editor.take_next_diff_request().unwrap(); let loaded_before = target.before.clone(); let loaded_after = target.after.clone(); @@ -12878,7 +12927,7 @@ mod tests { let changed = request_kind != "noop_navigation"; let outcome = if changed { Some(replacement) } else { None }; assert_eq!( - editor.apply_diff_result(refresh, Ok(Outcome::WorkingRefresh(outcome))), + editor.apply_diff_result(refresh, Ok(Outcome::CheckoutRefresh(outcome))), changed ); if changed { @@ -13832,6 +13881,83 @@ mod tests { assert!(editor.dirty()); editor.handle(Input::Char('u')); assert_eq!(editor.lines(), ["beta"]); + for refresh_error in [false, true] { + let path = directory.path("file.txt"); + fs::write(&path, "original").unwrap(); + let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); + historical_diff( + &mut editor, + &directory.0, + vec!["before".into()], + vec!["original".into()], + ); + let endpoints = ( + editor.diff().unwrap().before.clone(), + editor.diff().unwrap().after.clone(), + ); + editor.queue_checkout_refreshes(false); + let (_, request, _) = editor.take_next_diff_request().unwrap(); + let mut promoted = editor.diff.as_mut().unwrap().comparison.take().unwrap(); + promoted.editable_paths.insert("file.txt".into()); + editor.apply_diff_result(request, Ok(Outcome::CheckoutRefresh(Some(promoted)))); + editor.diff.as_mut().unwrap().focus = Focus::After; + editor.bind_diff_buffer(); + assert!(editor.diff_editing()); + let live = editor.buffer_id(); + type_text(&mut editor, "ix"); + assert_eq!(editor.mode(), Mode::Insert); + let comparison_id = editor.diff().unwrap().comparison_id; + editor.queue_checkout_refreshes(false); + let (_, request, _) = editor.take_next_diff_request().unwrap(); + if refresh_error { + assert!(editor.apply_diff_result(request, Err("checkout unavailable".into()))); + assert_eq!(editor.diff().unwrap().comparison_id, comparison_id); + } else { + let mut demoted = editor.diff.as_mut().unwrap().comparison.take().unwrap(); + demoted.editable_paths.clear(); + demoted.files[0].after = vec!["original".into()]; + demoted.files[0].after_eof = false; + demoted.files[0].hunks = crate::diff::line_hunks( + &demoted.files[0].before, + false, + &demoted.files[0].after, + false, + ); + assert!( + editor.apply_diff_result(request, Ok(Outcome::CheckoutRefresh(Some(demoted))),) + ); + } + assert!(!editor.diff_editable()); + assert_eq!(editor.lines(), ["original"]); + assert_eq!(editor.buffer(live).unwrap().lines, ["xoriginal"]); + assert!(editor.buffer(live).unwrap().dirty()); + assert_eq!(editor.buffer(live).unwrap().view.mode, Mode::Normal); + assert!( + editor + .apply(vec![Effect::Insert("blocked".into())]) + .is_err() + ); + editor.execute("w"); + assert_eq!(fs::read_to_string(&path).unwrap(), "original"); + editor.queue_checkout_refreshes(false); + let (_, request, _) = editor.take_next_diff_request().unwrap(); + let mut promoted = editor.diff.as_mut().unwrap().comparison.take().unwrap(); + promoted.editable_paths.insert("file.txt".into()); + editor.apply_diff_result(request, Ok(Outcome::CheckoutRefresh(Some(promoted)))); + assert!(editor.diff_editing()); + assert!(editor.diff().unwrap().error.is_none()); + assert_eq!(editor.buffer_id(), live); + assert_eq!(editor.lines(), ["xoriginal"]); + assert_eq!( + ( + editor.diff().unwrap().before.clone(), + editor.diff().unwrap().after.clone(), + ), + endpoints, + ); + editor.handle(Input::Char('u')); + assert_eq!(editor.lines(), ["original"]); + } } #[test] diff --git a/src/repository.rs b/src/repository.rs index cb17845..20b4aed 100644 --- a/src/repository.rs +++ b/src/repository.rs @@ -311,6 +311,9 @@ struct PreparedComparison { after: String, working_after: bool, working_anchor: Option, + pinned_commit: Option, + pinned_checkout: Option, + editable_after: bool, } pub struct Repository { @@ -933,7 +936,7 @@ impl Repository { "log", "--no-graph", "-r", - &format!("commit_id({})", serde_json::to_string(id).unwrap()), + &format!("present(commit_id({}))", serde_json::to_string(id).unwrap()), "-T", "conflict", ], @@ -1335,6 +1338,19 @@ impl Repository { } Ok(base.into()) } + fn pinned_checkout(&self, id: &str) -> Result, String> { + if self.kind == RepoKind::Git { + return Ok(Some(id.into())); + } + // Private remote history need not exist in the active jj operation. + let revset = format!("present(commit_id({}))", serde_json::to_string(id).unwrap()); + let identity = text(self.jj( + &self.root, + &["log", "--no-graph", "-r", &revset, "-T", "change_id"], + )?)?; + let identity = identity.trim(); + Ok((!identity.is_empty()).then(|| identity.into())) + } fn working_anchor(&self) -> Result, String> { if self.kind == RepoKind::Jujutsu { return Ok(Some( @@ -1417,11 +1433,20 @@ impl Repository { |(before, after, label)| (before, after, label.as_str()), ); let working_after = *after_target == RevisionTarget::Working; - let working_anchor = if working_after { - self.working_anchor()? - } else { + let checkout = self.working_anchor()?; + let pinned = if working_after { None + } else { + let (store, id) = self.resolve(after_target)?; + Some((store, id)) }; + let pinned_checkout = pinned + .as_ref() + .map(|(_, id)| self.pinned_checkout(id)) + .transpose()? + .flatten(); + let editable_after = + working_after || (pinned_checkout.is_some() && pinned_checkout == checkout); let left = self.snapshot(before_target)?; let same = before_target == after_target && (!working_after || buffers.is_empty()); let right = if same { @@ -1429,7 +1454,8 @@ impl Repository { } else if working_after { self.working_snapshot(buffers)? } else { - self.snapshot(after_target)? + let (store, id) = pinned.as_ref().unwrap(); + self.commit_snapshot(store, id)? }; let mut prepared = PreparedComparison { id: comparison_id, @@ -1439,31 +1465,91 @@ impl Repository { before: before_label.into(), after: after.label.clone(), working_after, - working_anchor, + working_anchor: working_after.then(|| checkout.clone()).flatten(), + pinned_commit: pinned.map(|(_, id)| id), + pinned_checkout, + editable_after, }; let comparison = self.build_comparison(&mut prepared, same)?; - if working_after && self.working_anchor()? != prepared.working_anchor { - return Err("Working checkout moved during comparison".into()); + if self.working_anchor()? != checkout { + return Err("Checkout moved during comparison".into()); } self.comparison = Some(prepared); Ok(comparison) } - /// Advance only a selected Working After, keeping its original Before frozen. - pub fn refresh_working_after( + /// Follow Working forward when enabled, or update a pinned After's eligibility. + /// Pinned sources and path catalogs never change when checkout eligibility does. + pub fn refresh_checkout( &mut self, comparison_id: u64, new_id: u64, + follow_working: bool, + buffers: &[(PathBuf, String)], + ) -> Result, String> { + let result = self.refresh_checkout_inner(comparison_id, new_id, follow_working, buffers); + if result.is_err() + && let Some(prepared) = self.comparison.as_mut() + && prepared.id == comparison_id + && !prepared.working_after + { + // The frontend revokes on error; a recovered checkout must emit a + // fresh promotion even if its identity did not change meanwhile. + prepared.editable_after = false; + } + result + } + + fn refresh_checkout_inner( + &mut self, + comparison_id: u64, + new_id: u64, + follow_working: bool, buffers: &[(PathBuf, String)], ) -> Result, String> { let Some(prepared) = self .comparison .as_ref() - .filter(|prepared| prepared.id == comparison_id && prepared.working_after) + .filter(|prepared| prepared.id == comparison_id) else { return Ok(None); }; + self.check_cancelled()?; + if prepared.working_after && !follow_working { + return Ok(None); + } + if prepared.pinned_checkout.is_none() + && let Some(id) = prepared.pinned_commit.as_deref() + { + // A privately loaded endpoint may later be imported into active jj. + // Retry only its frozen commit ID, never the mutable remote alias. + let identity = self.pinned_checkout(id)?; + self.comparison.as_mut().unwrap().pinned_checkout = identity; + } + let prepared = self.comparison.as_ref().unwrap(); let anchor = self.working_anchor()?; + if !prepared.working_after { + let editable = prepared.pinned_checkout.is_some() && prepared.pinned_checkout == anchor; + if editable == prepared.editable_after { + return Ok(None); + } + // No live contents enter the frozen comparison, including dirty + // frontend buffer overlays. + let mut prepared = self.comparison.take().unwrap(); + prepared.editable_after = editable; + let comparison = self.comparison_catalog(&prepared); + let result = self.check_cancelled().and_then(|()| { + if self.working_anchor()? != anchor { + return Err("Checkout moved during comparison".into()); + } + Ok(comparison) + }); + if result.is_ok() { + prepared.id = new_id; + } + self.comparison = Some(prepared); + return result.map(Some); + } if !self.working_advanced(prepared.working_anchor.as_deref(), anchor.as_deref())? { return Ok(None); } @@ -1560,35 +1646,55 @@ impl Repository { removed.insert(j); } } - let files: Vec<_> = files + prepared.files = files .into_iter() .enumerate() - .filter_map(|(i, f)| (!removed.contains(&i)).then_some(f)) + .filter_map(|(i, file)| { + (!removed.contains(&i)).then_some((file.path, file.old_path, file.status)) + }) .collect(); - let editable_paths = if prepared.working_after { - files + Ok(self.comparison_catalog(prepared)) + } + + fn comparison_catalog(&self, prepared: &PreparedComparison) -> Comparison { + let files = prepared + .files + .iter() + .map(|(path, old_path, status)| ChangedFile { + path: path.clone(), + old_path: old_path.clone(), + status: *status, + loaded: false, + before: vec![], + after: vec![], + before_eof: false, + after_eof: false, + hunks: vec![], + notice: None, + }) + .collect(); + let editable_paths = if prepared.editable_after { + prepared + .files .iter() - .filter(|file| { - right_view - .get(&file.path) + .filter(|(path, _, _)| { + prepared + .right + .get(path) .is_some_and(|data| matches!(data.mode.as_str(), "100644" | "100755")) }) - .map(|file| file.path.clone()) + .map(|(path, _, _)| path.clone()) .collect() } else { BTreeSet::new() }; - prepared.files = files - .iter() - .map(|file| (file.path.clone(), file.old_path.clone(), file.status)) - .collect(); - Ok(Comparison { + Comparison { before: prepared.before.clone(), after: prepared.after.clone(), files, root: self.root.clone(), editable_paths, - }) + } } /// Prepare one catalog entry using its original comparison's frozen sources. diff --git a/src/session.rs b/src/session.rs index 77f9ceb..56717d9 100644 --- a/src/session.rs +++ b/src/session.rs @@ -84,12 +84,13 @@ impl DiffWorker { } => repository .compare(id, &before, &after, mode, &buffers) .map(DiffOutcome::Comparison), - DiffOperation::RefreshWorking { + DiffOperation::RefreshCheckout { comparison_id, + follow_working, buffers, } => repository - .refresh_working_after(comparison_id, id, &buffers) - .map(DiffOutcome::WorkingRefresh), + .refresh_checkout(comparison_id, id, follow_working, &buffers) + .map(DiffOutcome::CheckoutRefresh), DiffOperation::LoadFile { comparison_id, file_index, @@ -1151,14 +1152,13 @@ impl EditorSession { self.editor.refresh_files() } - fn refresh_working_diffs_at(&mut self, now: Instant) { - if !self.diff_follow_working_copy - || now.saturating_duration_since(self.last_diff_refresh) < Duration::from_secs(1) - { + fn refresh_checkout_diffs_at(&mut self, now: Instant) { + if now.saturating_duration_since(self.last_diff_refresh) < Duration::from_secs(1) { return; } self.last_diff_refresh = now; - self.editor.queue_working_refreshes(); + self.editor + .queue_checkout_refreshes(self.diff_follow_working_copy); } /// Drains ready plugin work without waiting for command or server responses. @@ -1170,7 +1170,7 @@ impl EditorSession { let mut changed = self.refresh_files_at(now); changed |= self.synchronize_documents(); changed |= self.poll_battery(); - self.refresh_working_diffs_at(now); + self.refresh_checkout_diffs_at(now); changed |= self.poll_diff(); for update in self.lsp.poll() { match self.apply_update("core", update) { @@ -1234,7 +1234,7 @@ mod tests { }; #[test] - fn working_diff_polling_uses_injected_interval_and_never_queues_historical_or_disabled_views() { + fn checkout_diff_polling_uses_injected_interval_independently_of_endpoint_following() { use crate::diff::{Catalog, Category, Comparison, RepoKind, Revision, RevisionTarget}; let fixture = Fixture::new(); fixture.file("buffer.txt", "base"); @@ -1279,27 +1279,24 @@ mod tests { })), ); let now = session.last_diff_refresh; - session.diff_follow_working_copy = false; - session.refresh_working_diffs_at(now + Duration::from_secs(1)); - assert!(session.editor.take_next_diff_request().is_none()); - session.diff_follow_working_copy = true; - session.refresh_working_diffs_at(now + Duration::from_millis(999)); - assert!(session.editor.take_next_diff_request().is_none()); - session.refresh_working_diffs_at(now + Duration::from_secs(1)); - let request = session.editor.take_next_diff_request(); - assert_eq!(request.is_some(), working); - if let Some((_, refresh, operation)) = request { - assert!( - matches!(operation, DiffOperation::RefreshWorking { comparison_id, .. } - if comparison_id == id) - ); - session.refresh_working_diffs_at(now + Duration::from_secs(2)); + for (tick, follow) in [(1, false), (2, true)] { + session.diff_follow_working_copy = follow; + session.refresh_checkout_diffs_at(now + Duration::from_millis(tick * 1000 - 1)); assert!(session.editor.take_next_diff_request().is_none()); - assert!( - !session - .editor - .apply_diff_result(refresh, Ok(DiffOutcome::WorkingRefresh(None))) - ); + session.refresh_checkout_diffs_at(now + Duration::from_secs(tick)); + let request = session.editor.take_next_diff_request(); + assert_eq!(request.is_some(), !working || follow); + if let Some((_, refresh, operation)) = request { + assert!( + matches!(operation, DiffOperation::RefreshCheckout { comparison_id, follow_working, .. } + if comparison_id == id && follow_working == follow) + ); + assert!( + !session + .editor + .apply_diff_result(refresh, Ok(DiffOutcome::CheckoutRefresh(None))) + ); + } } } } diff --git a/src/tui.rs b/src/tui.rs index 09531d4..867d19a 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -4864,7 +4864,7 @@ mod tests { fn diff_borders_keep_full_revisions_and_each_panes_own_editability() { let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(180, 12)).unwrap(); let mut panels = WheelPanels::default(); - for case in ["working", "unlisted", "historical", "binary"] { + for case in ["working", "checked_out", "unlisted", "historical", "binary"] { let mut file = syntax_diff_file("new.rs", "fn old() {}", "fn new() {}"); file.status = FileStatus::Renamed; file.old_path = Some("old.rs".into()); @@ -4876,14 +4876,14 @@ mod tests { let comparison = diff.comparison.as_mut().unwrap(); comparison.before = "abcdef0123456789 (baseline)".into(); comparison.after = "fedcba9876543210 (destination)".into(); - if case != "unlisted" { + if matches!(case, "working" | "checked_out" | "binary") { comparison.editable_paths.insert("new.rs".into()); } - if case == "historical" { + if matches!(case, "historical" | "checked_out") { diff.after.as_mut().unwrap().target = crate::diff::RevisionTarget::Commit("fedcba9876543210".into()); } - let writable = case == "working"; + let writable = matches!(case, "working" | "checked_out"); assert_eq!(diff.after_editable(), writable, "{case}"); for focus in [Focus::Before, Focus::After] { diff.focus = focus; @@ -9737,6 +9737,7 @@ end } git(&["add", "."]); git(&["commit", "-qm", "base"]); + git(&["commit", "--allow-empty", "-qm", "unmatched checkout"]); for index in 0..8 { directory.file( &format!("file-{index}.txt"), @@ -9763,6 +9764,7 @@ end ready(&mut session, Phase::Before); session.handle(Input::Enter); session.handle(Input::Down); + session.handle(Input::Down); session.handle(Input::Enter); ready(&mut session, Phase::View); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 18)).unwrap(); diff --git a/tests/diff.rs b/tests/diff.rs index c3e665e..40e4e4f 100644 --- a/tests/diff.rs +++ b/tests/diff.rs @@ -548,7 +548,8 @@ fn branch_changes_default_and_mode_switches_compare_the_selected_divergent_tips( let comparison = diff.comparison.as_ref().unwrap(); assert_eq!(comparison.files.len(), 1); assert_eq!(comparison.files[0].path, Path::new("feature-only")); - assert_eq!(comparison.files[0].after, ["feature branch"]); + assert_eq!(session.editor().context().text, "feature branch\n"); + assert!(diff.after_editable()); assert_eq!( comparison.before, format!("merge base of {before} and {after} · {base}") @@ -590,7 +591,6 @@ fn branch_changes_default_and_mode_switches_compare_the_selected_divergent_tips( assert_eq!(diff.before.as_ref().unwrap().label, before); assert_eq!(diff.after.as_ref().unwrap().label, after); session.execute("diff.close"); - assert_eq!(session.editor().path(), Some(root.join("common").as_path())); session.execute("diff"); wait_until(&mut session, |session| { diff --git a/tests/lsp.rs b/tests/lsp.rs index da337ca..1553557 100644 --- a/tests/lsp.rs +++ b/tests/lsp.rs @@ -558,6 +558,7 @@ fn diff_repository(fixture: &Fixture, historical: bool) -> (String, Option