Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0b7d4d2c1f | |||
| 1f625a69c4 | |||
| 2e05725a38 | |||
| 7b4bc80538 | |||
| 165930fc1e | |||
| 843e4b9852 | |||
| 0d4601b612 | |||
| 0ca0d89b9e | |||
| 5186714ef5 |
+1
-1
@@ -6,7 +6,7 @@ edition = "2024"
|
||||
[dependencies]
|
||||
crossterm = "0.29"
|
||||
ignore = "0.4"
|
||||
ratatui = { version = "0.30", default-features = false, features = ["crossterm_0_29", "layout-cache", "underline-color", "unstable-backend-writer"] }
|
||||
ratatui = { version = "0.30", default-features = false, features = ["crossterm_0_29", "layout-cache", "underline-color", "unstable-backend-writer", "unstable-rendered-line-info"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
similar = { version = "2", default-features = false }
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
- [x] navigation control/actions: seek occurrence of character ('f' forwards, 'F' backwards by default; allow e.g. `2f` for seeking second occurrence instead of first).
|
||||
- [x] buffer ux improvement: enable mouse scrolling sideways so longer lines are easier to follow and read (e.g. in case they extend beyond the width of the buffer view).
|
||||
- [x] buffer ux improvement: add toggleable word wrap (no keybinding by default).
|
||||
- [ ] add tabs: tab support, editor action to open a buffer in a new tab, default tab navigation is `ctrl+option+h/l`, close tab action.
|
||||
- [ ] diff view: add character-wise change highlight (enabled by default).
|
||||
- [ ] diff view: lsp actions should act as they normally would. if an lsp action would open another file, then the file should be opened in another tab outside the diff view - if the referenced file/symbol is part of the changes in the diff, it should just be opened in the diff view.
|
||||
- this will require tab support.
|
||||
- [ ] diff view: change diff view modes; choose between split and unified diff view (split by default).
|
||||
- [ ] diff view: for split view, change split orientation (left/right vs above/below) - left/right by default.
|
||||
- [ ] diff view: github integration (code review) - add comments on a pr, add review comments at specific lines of files, request changes, approve pr, etc.
|
||||
- [ ] diff view: local review comments (same features as for github reviews) that allows review of local changes to deliver specific reviews to ai agents used for coding.
|
||||
- design question: would this require a server that the agent can interact with, or would local review files be fine? don't want to use server client stuff, if it would be easier and better to just use local files.
|
||||
@@ -68,6 +68,8 @@ The built-in `gt`/`gb` sequences move to the first/last line in Normal and Visua
|
||||
|
||||
Normal `dd` and `cc` delete/change complete rows, with leading counts. Bind `config.core.delete_line` or `config.core.change_line` to a single key or leader sequence for another route. A configured/disabled `d` or `c`, or choosing that key as the leader, overrides its built-in doubled sequence. These are dedicated line actions, not general operator–motion grammar.
|
||||
|
||||
`f<char>` / `F<char>` seeks forward/backward on the current line in Normal and Visual, with counts such as `2fx`. Rebind `config.core.seek_forward` / `config.core.seek_backward` to a single key or leader sequence; the action then waits for a literal target character. For example, `config.bind "s", config.core.seek_forward` makes `2sx` seek the second following `x`. Pass `mode: config.modes.visual` to install a Visual route. These native-only actions do not have Ex commands.
|
||||
|
||||
## Action reference
|
||||
|
||||
| Available modes | Methods on `config.core` |
|
||||
@@ -81,6 +83,7 @@ Normal `dd` and `cc` delete/change complete rows, with leading counts. Bind `con
|
||||
| Normal | `follow_link` |
|
||||
| Normal | `diff` |
|
||||
| Normal, Insert, Visual, Command, Search | `diff.close` |
|
||||
| Normal, Insert, Visual, Command, Search | `word_wrap.toggle` |
|
||||
| Normal | `jump_back`, `jump_forward` |
|
||||
| Normal | `lsp.definition`, `lsp.diagnostics`, `lsp.hover` |
|
||||
| Insert | `lsp.completion`, `snippet.next`, `snippet.previous` |
|
||||
@@ -90,6 +93,7 @@ Normal `dd` and `cc` delete/change complete rows, with leading counts. Bind `con
|
||||
| Normal, Visual | `repeat_last_edit` |
|
||||
| Normal, Visual | `file_picker`, `file_explorer` |
|
||||
| Normal, Visual | `document_start`, `document_end` |
|
||||
| Normal, Visual | `seek_forward`, `seek_backward` |
|
||||
| Normal, Insert, Visual | `line_start`, `line_end` |
|
||||
| Normal, Insert, Visual, Tree | `left`, `right` |
|
||||
| Normal, Insert, Visual, Selection, Tree | `down`, `up` |
|
||||
|
||||
+40
-9
@@ -16,6 +16,7 @@ Default controls and editing behavior. For custom bindings, see [configuration](
|
||||
| Normal, Visual | `I` (Shift+i) / `A` (Shift+a) insert at first nonblank / line end; Visual splits each touched row into a caret first |
|
||||
| Normal | `h`/`j`/`k`/`l` or arrows move every caret; `0`/`$` or Home/End |
|
||||
| Normal | `w`/`b`/`e` move every caret to next word start, previous/current word start, word end; `W`/`B`/`E` for whitespace-delimited WORDs |
|
||||
| Normal, Visual | `f<char>` seeks forward on the current line; `F<char>` seeks backward; `2fx` finds the second `x` after each caret |
|
||||
| Normal | `gf` follows a Markdown file link or heading fragment at the primary caret |
|
||||
| Normal, Visual | `gt` goes to the first line; `gb` goes to the last line, at first nonblank; Visual extends every selection |
|
||||
| Normal | Ctrl+o goes to an older jump location; Ctrl+i/Tab goes forward; counts select multiple locations |
|
||||
@@ -61,9 +62,17 @@ Word motions separate runs of Unicode letters/numbers/underscore from runs of pu
|
||||
|
||||
These keys type literal text in Insert mode. The corresponding actions can also be bound in Insert mode to move all insertion carets without splitting their undo group. Motions never edit text, add history, or clear an active search highlight.
|
||||
|
||||
## Character seeks
|
||||
|
||||
`f<char>` / `F<char>` moves every caret to the next / previous matching grapheme on its current line, excluding the current position. A count seeks that occurrence independently for each caret: `2fx` finds the second following `x`, and `2Fx` the second preceding `x`. With too few matches, that caret stays unchanged. Seeks never wrap or cross lines, and Unicode targets land on whole graphemes, including combining sequences and ZWJ emoji.
|
||||
|
||||
Normal and both Visual shapes support these motions, including read-only diff panes. Visual keeps its anchors; native edit replay retains the seek's direction, target, and count rather than its original distance. The following character is literal even if it is a digit, leader key, remapped key, or diff expansion bracket. Escape cancels only the pending seek without moving or collapsing selections. `f`/`F` remain literal text in Insert and command/search prompts; `gf` retains its Markdown-link behavior.
|
||||
|
||||
Bind `config.core.seek_forward` / `config.core.seek_backward` to another Normal or Visual key or leader sequence to start the same target-character prompt.
|
||||
|
||||
## Motion counts
|
||||
|
||||
Counts apply to `h`/`j`/`k`/`l`, arrows, `w`/`b`/`e`, `W`/`B`/`E`, and `n`/`N`, including remapped keys for those actions. Every caret moves independently before duplicate/overlapping targets normalize. Vertical counts preserve the clamping behavior of repeated steps through short rows; horizontal and word motions stop at buffer boundaries. Search counts wrap through the available matches.
|
||||
Counts apply to `h`/`j`/`k`/`l`, arrows, `w`/`b`/`e`, `W`/`B`/`E`, `f<char>`/`F<char>`, and `n`/`N`, including remapped keys for those actions. Every caret moves independently before duplicate/overlapping targets normalize. Vertical counts preserve the clamping behavior of repeated steps through short rows; horizontal and word motions stop at buffer boundaries. Character seeks require the requested occurrence on the current line or leave that caret unchanged. Search counts wrap through the available matches.
|
||||
|
||||
Unbound ASCII digits `1`–`9` start a count in Normal/Visual; subsequent digits extend it. `0` retains line-start behavior without a count and extends an existing count when bound to line-start. Explicit digit bindings, including disabled keys, take precedence. Count arithmetic saturates rather than overflowing; large counts stop at motion boundaries or wrap for search, without executing an unbounded repeat loop.
|
||||
|
||||
@@ -151,9 +160,19 @@ Paste is backend input: it is ignored in Normal, Command, Search, and Visual mod
|
||||
|
||||
## Mouse wheel scrolling
|
||||
|
||||
Mouse-wheel up/down scrolls the document view three rows per event without moving the primary or secondary carets, changing selections, or editing text. The caret may scroll offscreen; its terminal cursor is hidden until visible again. Navigation (including a motion blocked at the document boundary), editing, or buffer navigation restores caret-following. Idle updates and resizing preserve a wheel-scrolled view, clamped to the document.
|
||||
Mouse-wheel up/down scrolls the document view three display rows per event without moving the primary or secondary carets, changing selections, or editing text. The caret may scroll offscreen; its terminal cursor is hidden until visible again. Navigation (including a motion blocked at the document boundary), editing, or buffer navigation restores caret-following. Idle updates preserve a wheel-scrolled view, clamped to the document; resizing preserves unwrapped views and restores caret-following in ordinary wrapped buffers.
|
||||
|
||||
Wheel input over a hover popup or floating/docked file explorer scrolls that panel instead. Explorer scrolling preserves the selected file, filter, and keyboard focus; keyboard input resumes selection-following. An offscreen selected entry has no browsing cursor, while the filter input keeps its own cursor. Completion lists, command palettes, and startup notices consume wheel input without scrolling the background or editing text. Mouse clicks and dragging are not editing actions.
|
||||
Mouse-wheel left/right scrolls three display columns per event when wrapping is off. **Shift+wheel up/down** provides the same left/right scrolling when the terminal does not send horizontal wheel events. Tabs and wide Unicode characters use terminal-cell widths; scrolling stops at the longest line and remains detached from the caret until navigation or editing resumes. Wrapped text consumes horizontal wheel input without panning.
|
||||
|
||||
Vertical wheel input over a hover popup or floating/docked file explorer scrolls that panel instead. Explorer scrolling preserves the selected file, filter, and keyboard focus; keyboard input resumes selection-following. An offscreen selected entry has no browsing cursor, while the filter input keeps its own cursor. Horizontal wheel input over these panels is consumed without panning the document. Completion lists, command palettes, and startup notices consume wheel input without scrolling the background or editing text. Mouse clicks and dragging are not editing actions.
|
||||
|
||||
## Word wrap
|
||||
|
||||
Use `:word_wrap.toggle`, the **Toggle word wrap** palette action, or `config.core.word_wrap.toggle` in a custom binding. Wrapping defaults to **off** for new buffers and has **no default keybinding**. Each ordinary buffer retains its own setting; toggling does not edit text or enter undo history.
|
||||
|
||||
Toggling preserves the source caret and anchors a visible caret to its previous screen row in ordinary and diff buffers. File boundaries clamp the anchor when there are not enough preceding rows. Horizontal position follows the reflowed text.
|
||||
|
||||
Text wraps at word boundaries where possible, with grapheme-safe breaks for long words. Whitespace is preserved; tabs keep their source-column stops and wide/combining Unicode characters retain their geometry. Only the first display segment shows a line number. Syntax and semantic colors, diagnostics, search matches, selections, and cursors follow the wrapped text. Motions and counts still use source lines, so `j`/`k`, Home/End, and saved file contents are unchanged. Vertical wheel input scrolls continuation rows; switching wrapping off restores horizontal scrolling.
|
||||
|
||||
## Buffers
|
||||
|
||||
@@ -180,6 +199,8 @@ The floating explorer is centered, capped at 100 columns by 24 rows, and shrinks
|
||||
|
||||
The dock defaults to the right of the editor workspace. See [configuration preferences](configuration.md#preferences) to choose another side. Status and command/search prompts remain full-width; editor text, gutter, highlights, and cursors use the remaining rectangle. Focused panes prioritize their query on tiny terminals; unfocused docks shrink or hide to preserve editing space.
|
||||
|
||||
The explorer root path wraps within the pane width in floating, docked, and diff file views; file rows and keybinding help do not wrap. The path header grows to fit where space permits, reserving at least one file row. Very short panes clip the remaining path rather than hiding file navigation.
|
||||
|
||||
Both presentations default to Tree; [configuration preferences](configuration.md#preferences) can select List independently for each. Ctrl+t toggles the current focused instance without rescanning. List retains searchable full relative paths. Tree shows folders first, two-cell indentation, and basename-only files. Folder names end in `/` and use cyan text; files use the normal foreground color. Fold-state arrows are omitted. Maximal single-child directory chains share one row, such as `src/client/http/`; a directory containing one file still gets its own row.
|
||||
|
||||
Floating Tree opens in explorer **INSERT** mode; a new docked Tree opens in **NORMAL**. Press `i` in NORMAL to resume editing the existing filter; the `i` itself is not added to the query. Typing and Backspace filter full relative paths by case-insensitive whitespace tokens. These are explorer-local modes, not changes to the file buffer's mode. Matching nodes appear with ancestor context, and query changes initially select a direct match. Escape in INSERT changes only to NORMAL: the filter, visible results, highlighted item, and explorer focus remain. NORMAL displays the active filter and navigates that filtered tree. Escape from NORMAL returns focus to the buffer (or closes floating search). List always accepts query typing immediately.
|
||||
@@ -220,9 +241,13 @@ The floating selector has **Revisions / Bookmarks** tabs for jj, or **Commits /
|
||||
| Enter | Choose the entry |
|
||||
| Esc | Cancel loading; After → return to Before; Before → clear query without closing |
|
||||
|
||||
Remote references such as `some-bookmark@origin` appear from remote advertisements, including refs never fetched locally. Selecting one retrieves missing content into a temporary comparison repository. **Load history from origin** (or another configured remote) in Revisions/Commits imports searchable remote history without advancing the selection stage. These operations may require network access and the repository's existing credentials; errors stay in the selector. They do not update active refs, snapshot jj's working copy, or change the checkout.
|
||||
The selector initially lists local revisions and known tracking refs **without contacting remotes**. **Load refs from origin** (or another configured remote) in Bookmarks/Branches/Tags lists advertised references, including refs never fetched locally, without advancing the selection stage. Selecting a reference such as `some-bookmark@origin` reuses available content or fetches the missing selected reference into a temporary comparison repository. **Load history from origin** in Revisions/Commits explicitly imports searchable remote history. Network operations use the repository's existing credentials; errors stay in the selector. They do not update active refs, snapshot jj's working copy, or change the checkout.
|
||||
|
||||
The changed-file explorer is **docked on the right**. Modified files show Before on the left and After on the right. Added files show only After, using the full text workspace; deleted files show only Before. Rename-only files show one empty pane, with the old and new paths in the explorer; renames with text or final-newline changes retain the split view. Before and historical/remote After snapshots are read-only. Selecting **Working checkout** as After binds eligible regular UTF-8 files to normal editor buffers, reusing already-open dirty buffers and their undo history. Focus After to use native editing, selections, search, completion, undo/redo, and `:w`. Removed files, rename-only files, binary files, symlinks, and submodules remain read-only. Closing the diff keeps its buffers and unsaved edits.
|
||||
Comparison discovery builds the changed-path/status catalog first. File text, notices, and hunks load asynchronously when a file is selected and remain cached for that comparison. Failed file loads can be retried by selecting the file again; cancelled or superseded results cannot replace the current selection. Working-checkout comparisons still capture working bytes and the historical content needed to identify changes, preserving the frozen Before snapshot and unsaved After buffers.
|
||||
|
||||
The changed-file explorer is **docked on the right**. Modified files show Before on the left and After on the right. Added files show only After, using the full text workspace; deleted files show only Before. Rename-only files show one empty pane, with the old and new paths in the explorer; renames with text or final-newline changes retain the split view. Before and historical/remote After snapshots are read-only. Selecting **Working checkout** as After binds eligible regular UTF-8 files to normal editor buffers, reusing already-open dirty buffers and their undo history. Editable After supports native edits, completion, undo/redo, and `:w`. Removed files, rename-only files, binary files, symlinks, and submodules remain read-only. Closing the diff keeps its buffers and unsaved edits.
|
||||
|
||||
Text panes use normal buffer interaction regardless of writability: motions and counts, search, Visual/Visual Line selections, multiple carets, yank, remapped bindings, and non-mutating actions work in Before and After. Read-only snapshots reject insert/delete/change/paste, repeat edits, undo/redo, mutating plugin effects, and saves, including saves to another path. Their text retains the revision's final newline. Snapshots use separate virtual LSP document identities and do not appear in ordinary buffer lists or dirty-buffer comparisons. Closing the diff disposes snapshots and restores the live or original buffer; explicit native navigation to an ordinary buffer leaves the comparison at that destination.
|
||||
|
||||
Use the ordinary **file-picker/file-explorer actions and bindings**: Space `o` opens a floating picker, Space `e` opens/focuses the docked explorer, and Ctrl+w closes the focused picker/explorer and reclaims its width. Remapped bindings work too; Ctrl+e is no longer reserved by the diff. The changed-path catalog retains A/M/D/R markers and the normal compact tree, filtering, folder expansion, and configurable `tree`/`selection` navigation. It includes removed and historical paths without scanning the working directory. Enter toggles a folder or opens the selected comparison file and focuses its first visible text pane: After for added files, otherwise Before. While the explorer is hidden, Tab/Shift+Tab cycles between visible text panes only; a single-pane view keeps its focus.
|
||||
|
||||
@@ -230,17 +255,23 @@ File rows show colored **A** (added, green), **M** (modified, yellow), **D** (re
|
||||
|
||||
Text panes show change excerpts with **10 unchanged lines above and below** each change, preserving original file line numbers. Overlapping or touching excerpts merge; omitted stretches show an unchanged-line count in both panes. Files with only metadata changes show their notice or “No text changes” rather than unrelated source.
|
||||
|
||||
In **Normal mode** with a text pane focused, **`[` expands above** and **`]` expands below**, revealing up to **15 lines** per keypress. Editable After targets the excerpt containing the caret; read-only panes target the excerpt at the top of the viewport. In editable After's Insert mode these keys insert literal text. In read-only panes, a hidden-line marker targets the following excerpt, or the preceding excerpt at the end of the file. Expansion clamps at file boundaries and merges excerpts when they meet. Existing visible source stays anchored; expanding above a leading/interior marker brings the newly revealed context into view. Opening a file resets its excerpts to the configured initial context. Live edits recompute changes while retaining expanded context; the caret stays visible even when the last text change is reverted.
|
||||
In **Normal mode** with a text pane focused, **`[` expands above** and **`]` expands below** the excerpt containing the caret, revealing up to **15 lines** per keypress in either pane. In editable After's Insert mode these keys insert literal text. Expansion clamps at file boundaries and merges excerpts when they meet. Existing visible source stays anchored. Opening a file resets its excerpts to the configured initial context. Live edits recompute changes while retaining expanded context; the caret stays visible even when the last text change is reverted.
|
||||
|
||||
Editable After navigation stays within the shown source lines. At an excerpt boundary, Up/Down or `j`/`k` jumps directly to the next/previous excerpt, and counts count visible lines rather than omitted lines. This also applies to remapped movement keys, Visual-mode movement, and Insert-mode arrows. Word motions, `gt`/`gb`, search, and occurrence selection use the visible excerpts; `gt`/`gb` target their first/last source line. Hidden short lines do not clamp the caret column. The viewport retains its vertical and horizontal offsets when movement reverses, scrolling only when the caret crosses a visible edge, as in a normal buffer. Moving or restoring a caret never reveals omitted context: only Normal-mode `[`/`]` expands existing excerpts. Edits still recompute changed regions and retain previously expanded context.
|
||||
Live typing highlights only the displayed source rows while retaining each snapshot's original syntax tree for multiline and injected-language context. Line-width caches and unchanged hunk layouts avoid repeated full-file work; edits that change line alignment or revert changes recompute the diff. Already-queued printable Insert keys share a redraw, but every key still passes through normal bindings, snippet handling, and undo history. Mode changes and other terminal events retain their order.
|
||||
|
||||
Text-pane navigation stays within the shown source lines. At an excerpt boundary, Up/Down or `j`/`k` jumps directly to the next/previous excerpt, and counts count visible lines rather than omitted lines. This also applies to remapped movement keys, Visual-mode movement, and editable After's Insert-mode arrows. Word motions, `gt`/`gb`, search, and occurrence selection use the visible excerpts; `gt`/`gb` target their first/last source line. Hidden short lines do not clamp the caret column. The shared vertical viewport and each pane's horizontal offset stay fixed when movement reverses, scrolling only when the caret crosses a visible edge, as in a normal buffer. Moving or restoring a caret never reveals omitted context: only Normal-mode `[`/`]` expands existing excerpts. Edits still recompute changed regions and retain previously expanded context.
|
||||
|
||||
Configure these counts in Ruby with `config.diff_context_lines = 10` (nonnegative; `0` shows changes only) and `config.diff_expand_lines = 15` (positive).
|
||||
|
||||
In split diffs, changed text rows use muted red backgrounds in Before and muted green backgrounds in After, including blank tails and real empty lines. Entirely added or deleted files retain the normal background; colored line numbers and `+`/`-` gutter markers indicate their changes. Foreground colors use the editor's bundled Tree-sitter syntax highlighting, parsed independently for each snapshot and its path (including the old path for renames). Editable After also uses its live buffer's semantic highlighting, selections, and diagnostics. Unknown languages retain the normal foreground; historical snapshots do not borrow live-buffer LSP tokens.
|
||||
Diff views inherit the originating buffer's word-wrap setting. `word_wrap.toggle` switches both panes together, including read-only snapshots, without changing ordinary-buffer settings. Each aligned source row uses the taller side's wrapped height; shorter-side continuation padding stays blank so subsequent source lines remain aligned. Excerpts and explicit expansion still use source-line counts.
|
||||
|
||||
In split diffs, changed text rows use muted red backgrounds in Before and muted green backgrounds in After, including blank tails and real empty lines. Entirely added or deleted files retain the normal background; colored line numbers and `+`/`-` gutter markers indicate their changes. Foreground colors use the editor's bundled Tree-sitter syntax highlighting, parsed independently for each snapshot and its path (including the old path for renames). Native text panes also display their buffer's selections, search matches, semantic highlighting, and diagnostics. Unknown languages retain the normal foreground; historical snapshots do not borrow live-buffer LSP tokens.
|
||||
|
||||
Missing counterparts use adjacent muted `╱` diagonals (U+2571, box drawings light diagonal) on the normal terminal background, one per column. Unlike the text slash `/`, this glyph is designed to reach cell corners so diagonals can join end to end across successive rows. Pixel-perfect joins depend on the terminal's font and line-spacing rendering. Actual empty source lines retain their normal/change background and are not hatched.
|
||||
|
||||
In Normal mode, Tab/Shift+Tab cycles focus between Files and the visible text panes, skipping absent panes. Read-only text panes use Up/Down or Ctrl+j/k for shared vertical scrolling, Left/Right or Ctrl+h/l for independent horizontal scrolling, and Home/End for the first/last displayed row; editable After uses normal buffer motions. Wheel input over text panes scrolls the visible panes without moving the editable caret; over Files it navigates visible tree rows without opening them. Escape cancels modes, prompts, or filtering, or returns picker focus to the first visible text pane; **it never closes the diff**. Use **`:diff.close`**, find `diff.close` in the palette, or bind **`config.core.diff.close`** to leave the comparison explicitly. The dotted command is intentional; there is no `:diffclose` alias.
|
||||
In Normal mode, Tab/Shift+Tab cycles focus between Files and the visible text panes, skipping absent panes. Switching Before/After synchronizes the primary cursor by aligned diff row and display column. A missing counterpart uses the nearest visible source row; shorter lines clamp the column while retaining the preferred display column for the return trip. Vertical wheel input scrolls the visible panes without moving the focused text caret; switching sides by vertical wheel also synchronizes the cursor. Over Files it navigates visible tree rows without opening them. Horizontal wheel input in either split synchronizes the Before/After horizontal offsets, bounded by the longer side, without changing focus, cursor, or selections; over Files it is consumed. Added/deleted files retain single-pane scrolling. Escape cancels modes, prompts, or filtering, or returns picker focus to the last bound text pane; **it never closes the diff**. Use **`:diff.close`**, find `diff.close` in the palette, or bind **`config.core.diff.close`** to leave the comparison explicitly. The dotted command is intentional; there is no `:diffclose` alias.
|
||||
|
||||
Both diff panes apply available semantic tokens from their own buffer, independently of keyboard focus. Historical snapshots never borrow the working buffer's tokens; live After tokens must match the displayed text revision. Missing or invalidated tokens leave Tree-sitter syntax colors intact. Semantic colors and modifiers preserve the change backgrounds.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
+691
-49
@@ -44,6 +44,7 @@ pub enum RevisionTarget {
|
||||
Working,
|
||||
Commit(String),
|
||||
Remote { remote: String, reference: String },
|
||||
ListRemote(String),
|
||||
FetchRemote(String),
|
||||
}
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
@@ -78,6 +79,8 @@ pub struct ChangedFile {
|
||||
pub path: PathBuf,
|
||||
pub old_path: Option<PathBuf>,
|
||||
pub status: FileStatus,
|
||||
/// False means text, EOF flags, hunks and notices have not been prepared.
|
||||
pub loaded: bool,
|
||||
pub before: Vec<String>,
|
||||
pub after: Vec<String>,
|
||||
pub before_eof: bool,
|
||||
@@ -91,22 +94,33 @@ pub struct Comparison {
|
||||
pub after: String,
|
||||
pub files: Vec<ChangedFile>,
|
||||
pub root: PathBuf,
|
||||
/// Regular working-After candidates; loaded binary details are excluded.
|
||||
pub editable_paths: BTreeSet<PathBuf>,
|
||||
}
|
||||
#[derive(Debug)]
|
||||
pub enum Operation {
|
||||
Discover,
|
||||
ListRemote(String),
|
||||
FetchRemote(String),
|
||||
Compare {
|
||||
before: Revision,
|
||||
after: Revision,
|
||||
buffers: Box<[(PathBuf, String)]>,
|
||||
},
|
||||
LoadFile {
|
||||
comparison_id: u64,
|
||||
file_index: usize,
|
||||
},
|
||||
}
|
||||
#[derive(Debug)]
|
||||
pub enum Outcome {
|
||||
Catalog(Catalog),
|
||||
Comparison(Comparison),
|
||||
File {
|
||||
comparison_id: u64,
|
||||
file_index: usize,
|
||||
file: ChangedFile,
|
||||
},
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum Phase {
|
||||
@@ -158,12 +172,17 @@ struct SelectorPosition {
|
||||
enum RequestKind {
|
||||
Catalog,
|
||||
Comparison,
|
||||
File {
|
||||
comparison_id: u64,
|
||||
file_index: usize,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct DiffState {
|
||||
pub catalog: Option<Catalog>,
|
||||
pub phase: Phase,
|
||||
pub word_wrap: bool,
|
||||
pub before: Option<Revision>,
|
||||
pub after: Option<Revision>,
|
||||
pub category: usize,
|
||||
@@ -196,6 +215,7 @@ pub struct DiffState {
|
||||
before_position: Option<SelectorPosition>,
|
||||
max_before: usize,
|
||||
max_after: usize,
|
||||
after_widths: Vec<usize>,
|
||||
}
|
||||
|
||||
impl DiffState {
|
||||
@@ -203,6 +223,7 @@ impl DiffState {
|
||||
Self {
|
||||
catalog: None,
|
||||
phase: Phase::Before,
|
||||
word_wrap: false,
|
||||
before: None,
|
||||
after: None,
|
||||
category: 0,
|
||||
@@ -235,6 +256,7 @@ impl DiffState {
|
||||
before_position: None,
|
||||
max_before: 0,
|
||||
max_after: 0,
|
||||
after_widths: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,6 +273,13 @@ impl DiffState {
|
||||
fn queue(&mut self, operation: Operation) {
|
||||
self.request_kind = Some(match operation {
|
||||
Operation::Compare { .. } => RequestKind::Comparison,
|
||||
Operation::LoadFile {
|
||||
comparison_id,
|
||||
file_index,
|
||||
} => RequestKind::File {
|
||||
comparison_id,
|
||||
file_index,
|
||||
},
|
||||
_ => RequestKind::Catalog,
|
||||
});
|
||||
self.pending = Some(operation);
|
||||
@@ -259,6 +288,24 @@ impl DiffState {
|
||||
}
|
||||
|
||||
pub(crate) fn apply_result(&mut self, id: u64, result: Result<Outcome, String>) {
|
||||
if self.request_kind.is_none() {
|
||||
return;
|
||||
}
|
||||
if let Ok(Outcome::File {
|
||||
comparison_id,
|
||||
file_index,
|
||||
..
|
||||
}) = &result
|
||||
&& (self.comparison_id != Some(*comparison_id)
|
||||
|| self.file != *file_index
|
||||
|| self.request_kind
|
||||
!= Some(RequestKind::File {
|
||||
comparison_id: *comparison_id,
|
||||
file_index: *file_index,
|
||||
}))
|
||||
{
|
||||
return;
|
||||
}
|
||||
let expected = self.request_kind.take();
|
||||
self.loading = false;
|
||||
self.pending = None;
|
||||
@@ -313,8 +360,37 @@ impl DiffState {
|
||||
} else {
|
||||
self.first_text_focus()
|
||||
};
|
||||
self.align_file();
|
||||
self.error = None;
|
||||
self.prepare_selected_file();
|
||||
}
|
||||
Ok(Outcome::File {
|
||||
comparison_id,
|
||||
file_index,
|
||||
file,
|
||||
}) if expected
|
||||
== Some(RequestKind::File {
|
||||
comparison_id,
|
||||
file_index,
|
||||
}) =>
|
||||
{
|
||||
if let Some(comparison) = &mut self.comparison
|
||||
&& let Some(selected) = comparison.files.get_mut(file_index)
|
||||
&& selected.path == file.path
|
||||
&& file.loaded
|
||||
{
|
||||
if file
|
||||
.notice
|
||||
.as_deref()
|
||||
.is_some_and(|notice| notice.contains("Binary or non-UTF-8"))
|
||||
{
|
||||
comparison.editable_paths.remove(&file.path);
|
||||
}
|
||||
*selected = file;
|
||||
self.align_file();
|
||||
self.normalize_focus();
|
||||
self.error = None;
|
||||
} else {
|
||||
self.error = Some("Unexpected changed-file response".into());
|
||||
}
|
||||
}
|
||||
Ok(_) => self.error = Some("Unexpected repository response".into()),
|
||||
Err(error) => self.error = Some(error),
|
||||
@@ -481,6 +557,8 @@ impl DiffState {
|
||||
};
|
||||
if let RevisionTarget::FetchRemote(remote) = &revision.target {
|
||||
self.queue(Operation::FetchRemote(remote.clone()));
|
||||
} else if let RevisionTarget::ListRemote(remote) = &revision.target {
|
||||
self.queue(Operation::ListRemote(remote.clone()));
|
||||
} else if self.phase == Phase::Before {
|
||||
self.before_position = Some(SelectorPosition {
|
||||
category: self.category,
|
||||
@@ -597,6 +675,9 @@ impl DiffState {
|
||||
else {
|
||||
return PaneLayout::Empty;
|
||||
};
|
||||
if !file.loaded {
|
||||
return PaneLayout::Empty;
|
||||
}
|
||||
match file.status {
|
||||
FileStatus::Added => PaneLayout::After,
|
||||
FileStatus::Removed => PaneLayout::Before,
|
||||
@@ -609,6 +690,45 @@ impl DiffState {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn bound_horizontal(&self, focus: Focus, left: usize, width: usize) -> usize {
|
||||
let columns = match focus {
|
||||
Focus::Before => self.max_before,
|
||||
Focus::After => self.max_after,
|
||||
Focus::Files => return 0,
|
||||
};
|
||||
let columns = if self.pane_layout() == PaneLayout::Split {
|
||||
self.max_before.max(self.max_after)
|
||||
} else {
|
||||
columns
|
||||
};
|
||||
left.min(columns.saturating_add(1).saturating_sub(width.max(1)))
|
||||
}
|
||||
|
||||
pub(crate) fn scroll_horizontal(&mut self, focus: Focus, right: bool, width: usize) {
|
||||
let left = match focus {
|
||||
Focus::Before => self.left_before,
|
||||
Focus::After => self.left_after,
|
||||
Focus::Files => return,
|
||||
};
|
||||
let left = if right {
|
||||
left.saturating_add(3)
|
||||
} else {
|
||||
left.saturating_sub(3)
|
||||
};
|
||||
let left = self.bound_horizontal(focus, left, width);
|
||||
if self.pane_layout() == PaneLayout::Split {
|
||||
self.left_before = left;
|
||||
self.left_after = left;
|
||||
} else {
|
||||
match focus {
|
||||
Focus::Before => self.left_before = left,
|
||||
Focus::After => self.left_after = left,
|
||||
Focus::Files => unreachable!(),
|
||||
}
|
||||
}
|
||||
self.follow_caret = false;
|
||||
}
|
||||
|
||||
pub(crate) fn first_text_focus(&self) -> Focus {
|
||||
if self.pane_layout() == PaneLayout::After {
|
||||
Focus::After
|
||||
@@ -658,16 +778,20 @@ impl DiffState {
|
||||
|
||||
pub fn after_editable(&self) -> bool {
|
||||
self.phase == Phase::View
|
||||
&& self.selected_file_loaded()
|
||||
&& matches!(self.pane_layout(), PaneLayout::Split | PaneLayout::After)
|
||||
&& 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))
|
||||
comparison.files.get(self.file).is_some_and(|file| {
|
||||
comparison.editable_paths.contains(&file.path)
|
||||
&& file
|
||||
.notice
|
||||
.as_deref()
|
||||
.is_none_or(|notice| !notice.contains("Binary or non-UTF-8"))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -686,25 +810,103 @@ impl DiffState {
|
||||
self.live_buffer = None;
|
||||
self.live_revision = None;
|
||||
self.follow_caret = false;
|
||||
self.align_file();
|
||||
self.prepare_selected_file();
|
||||
true
|
||||
}
|
||||
|
||||
pub fn after_display_row(&self, source_row: usize) -> Option<usize> {
|
||||
pub fn selected_file_loaded(&self) -> bool {
|
||||
self.comparison
|
||||
.as_ref()
|
||||
.and_then(|comparison| comparison.files.get(self.file))
|
||||
.is_some_and(|file| file.loaded)
|
||||
}
|
||||
|
||||
fn prepare_selected_file(&mut self) {
|
||||
self.pending = None;
|
||||
self.request_kind = None;
|
||||
self.loading = false;
|
||||
self.error = None;
|
||||
if self.selected_file_loaded() {
|
||||
self.align_file();
|
||||
} else {
|
||||
self.align_file();
|
||||
if let Some(comparison_id) = self.comparison_id
|
||||
&& self
|
||||
.comparison
|
||||
.as_ref()
|
||||
.is_some_and(|comparison| !comparison.files.is_empty())
|
||||
{
|
||||
self.queue(Operation::LoadFile {
|
||||
comparison_id,
|
||||
file_index: self.file,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn source_display_row(&self, focus: Focus, source_row: usize) -> Option<usize> {
|
||||
self.display_rows.iter().position(|display| {
|
||||
matches!(*display, DisplayRow::Line(index) if self.rows[index].after == Some(source_row))
|
||||
matches!(*display, DisplayRow::Line(index) if self.source_row(focus, index) == Some(source_row))
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn after_rows(&self) -> impl DoubleEndedIterator<Item = usize> + '_ {
|
||||
self.excerpt_ranges
|
||||
.iter()
|
||||
.flat_map(|&(start, end)| self.rows[start..end].iter().filter_map(|row| row.after))
|
||||
pub fn after_display_row(&self, source_row: usize) -> Option<usize> {
|
||||
self.source_display_row(Focus::After, source_row)
|
||||
}
|
||||
|
||||
pub(crate) fn clamp_after_row(&self, row: usize) -> Option<usize> {
|
||||
fn source_row(&self, focus: Focus, index: usize) -> Option<usize> {
|
||||
match focus {
|
||||
Focus::Before => self.rows[index].before,
|
||||
Focus::After => self.rows[index].after,
|
||||
Focus::Files => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn source_rows(&self, focus: Focus) -> impl DoubleEndedIterator<Item = usize> + '_ {
|
||||
self.excerpt_ranges.iter().flat_map(move |&(start, end)| {
|
||||
(start..end).filter_map(move |index| self.source_row(focus, index))
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn snapshot_lines(&self, focus: Focus) -> Vec<String> {
|
||||
let file = &self.comparison.as_ref().expect("snapshot comparison").files[self.file];
|
||||
let (source, eof, live) = match focus {
|
||||
Focus::Before => (&file.before, file.before_eof, false),
|
||||
Focus::After => (
|
||||
&file.after,
|
||||
file.after_eof,
|
||||
self.live_files.contains(&self.file),
|
||||
),
|
||||
Focus::Files => unreachable!("snapshot belongs to a text pane"),
|
||||
};
|
||||
let mut lines = source.clone();
|
||||
if !live && (eof || lines.is_empty()) {
|
||||
lines.push(String::new());
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
pub(crate) fn corresponding_row(&self, from: Focus, to: Focus, row: usize) -> Option<usize> {
|
||||
let aligned = self.rows.iter().position(|aligned| match from {
|
||||
Focus::Before => aligned.before == Some(row),
|
||||
Focus::After => aligned.after == Some(row),
|
||||
Focus::Files => false,
|
||||
})?;
|
||||
self.display_rows
|
||||
.iter()
|
||||
.filter_map(|display| match *display {
|
||||
DisplayRow::Line(index) => self
|
||||
.source_row(to, index)
|
||||
.map(|row| (index.abs_diff(aligned), row)),
|
||||
DisplayRow::Fold { .. } => None,
|
||||
})
|
||||
.min_by_key(|&(distance, row)| (distance, row))
|
||||
.map(|(_, row)| row)
|
||||
}
|
||||
|
||||
pub(crate) fn clamp_source_row(&self, focus: Focus, row: usize) -> Option<usize> {
|
||||
let mut previous = None;
|
||||
for visible in self.after_rows() {
|
||||
for visible in self.source_rows(focus) {
|
||||
if visible >= row {
|
||||
return Some(match previous {
|
||||
Some(previous) if row - previous < visible - row => previous,
|
||||
@@ -742,6 +944,24 @@ impl DiffState {
|
||||
}
|
||||
let file = &self.comparison.as_ref().unwrap().files[self.file];
|
||||
let was_live = self.live_files.contains(&self.file);
|
||||
let changed_lines = changed_line_range(&file.after, lines);
|
||||
if was_live && changed_lines.0 == changed_lines.1 && changed_lines.1 == changed_lines.2 {
|
||||
self.live_revision = Some(revision);
|
||||
return;
|
||||
}
|
||||
if was_live && stable_after_edit(file, lines, changed_lines) {
|
||||
let file = &mut self.comparison.as_mut().unwrap().files[self.file];
|
||||
replace_after_lines(
|
||||
&mut file.after,
|
||||
&mut self.after_widths,
|
||||
&mut self.max_after,
|
||||
lines,
|
||||
changed_lines,
|
||||
);
|
||||
self.left_after = self.left_after.min(self.max_after);
|
||||
self.live_revision = Some(revision);
|
||||
return;
|
||||
}
|
||||
let mut old = file.after.iter().map(String::as_str).collect::<Vec<_>>();
|
||||
if !was_live && (file.after_eof || old.is_empty()) {
|
||||
old.push("");
|
||||
@@ -783,10 +1003,13 @@ impl DiffState {
|
||||
.map(map_after);
|
||||
let was_changed = file_is_changed(file);
|
||||
let file = &mut self.comparison.as_mut().unwrap().files[self.file];
|
||||
file.after.resize_with(lines.len(), String::new);
|
||||
for (target, line) in file.after.iter_mut().zip(lines) {
|
||||
target.clone_from(line);
|
||||
}
|
||||
replace_after_lines(
|
||||
&mut file.after,
|
||||
&mut self.after_widths,
|
||||
&mut self.max_after,
|
||||
lines,
|
||||
changed_lines,
|
||||
);
|
||||
file.after_eof = lines.len() > 1 && lines.last().is_some_and(String::is_empty);
|
||||
file.hunks = line_hunks(
|
||||
&file.before,
|
||||
@@ -859,35 +1082,31 @@ impl DiffState {
|
||||
self.left_before = 0;
|
||||
self.left_after = 0;
|
||||
self.max_before = 0;
|
||||
self.max_after = 0;
|
||||
self.after_widths.clear();
|
||||
}
|
||||
self.max_after = 0;
|
||||
let Some(file) = self
|
||||
.comparison
|
||||
.as_ref()
|
||||
.and_then(|comparison| comparison.files.get(self.file))
|
||||
.filter(|file| file.loaded)
|
||||
else {
|
||||
self.after_widths.clear();
|
||||
self.max_before = 0;
|
||||
self.max_after = 0;
|
||||
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)
|
||||
};
|
||||
if reset {
|
||||
self.max_before = width(&file.before);
|
||||
self.max_before = file
|
||||
.before
|
||||
.iter()
|
||||
.map(|line| line_width(line))
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
self.after_widths
|
||||
.extend(file.after.iter().map(|line| line_width(line)));
|
||||
self.max_after = self.after_widths.iter().copied().max().unwrap_or(0);
|
||||
}
|
||||
self.max_after = width(&file.after);
|
||||
let after_len = if self.live_files.contains(&self.file) {
|
||||
live_content(&file.after, file.after_eof).len()
|
||||
} else {
|
||||
@@ -1007,8 +1226,8 @@ impl DiffState {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn expand_after_excerpt(&mut self, below: bool, source_row: usize) {
|
||||
let Some(display_row) = self.after_display_row(source_row) else {
|
||||
pub(crate) fn expand_source_excerpt(&mut self, focus: Focus, below: bool, source_row: usize) {
|
||||
let Some(display_row) = self.source_display_row(focus, source_row) else {
|
||||
return;
|
||||
};
|
||||
self.expand_excerpt_at(below, self.display_rows[display_row]);
|
||||
@@ -1095,6 +1314,89 @@ fn append_rows(
|
||||
}
|
||||
}
|
||||
|
||||
fn line_width(line: &str) -> usize {
|
||||
#[cfg(test)]
|
||||
tests::WIDTH_SCANS.with(|scans| scans.set(scans.get() + 1));
|
||||
line.graphemes(true).fold(0, |column, grapheme| {
|
||||
column
|
||||
+ if grapheme == "\t" {
|
||||
4 - column % 4
|
||||
} else {
|
||||
UnicodeWidthStr::width(grapheme)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn changed_line_range(old: &[String], new: &[String]) -> (usize, usize, usize) {
|
||||
let start = old
|
||||
.iter()
|
||||
.zip(new)
|
||||
.take_while(|(old, new)| old == new)
|
||||
.count();
|
||||
let suffix = old[start..]
|
||||
.iter()
|
||||
.rev()
|
||||
.zip(new[start..].iter().rev())
|
||||
.take_while(|(old, new)| old == new)
|
||||
.count();
|
||||
(start, old.len() - suffix, new.len() - suffix)
|
||||
}
|
||||
|
||||
fn stable_after_edit(
|
||||
file: &ChangedFile,
|
||||
lines: &[String],
|
||||
(start, old_end, new_end): (usize, usize, usize),
|
||||
) -> bool {
|
||||
if old_end != start + 1
|
||||
|| new_end != start + 1
|
||||
|| file.after_eof != (lines.len() > 1 && lines.last().is_some_and(String::is_empty))
|
||||
|| live_content(&file.after, file.after_eof).len()
|
||||
!= live_content(lines, file.after_eof).len()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let old = &file.after[start];
|
||||
let new = &lines[start];
|
||||
// With neither token matching another line, the equality graph seen by Myers
|
||||
// is unchanged. Hunks, alignment, excerpts and scroll anchors remain exact.
|
||||
!file.before.iter().any(|line| line == old || line == new)
|
||||
&& !file
|
||||
.after
|
||||
.iter()
|
||||
.enumerate()
|
||||
.any(|(index, line)| index != start && (line == old || line == new))
|
||||
}
|
||||
|
||||
fn replace_after_lines(
|
||||
after: &mut Vec<String>,
|
||||
widths: &mut Vec<usize>,
|
||||
max_width: &mut usize,
|
||||
lines: &[String],
|
||||
(start, old_end, new_end): (usize, usize, usize),
|
||||
) {
|
||||
let removed_max = widths[start..old_end].iter().copied().max().unwrap_or(0);
|
||||
if old_end == new_end {
|
||||
for index in start..new_end {
|
||||
if after[index] != lines[index] {
|
||||
after[index].clone_from(&lines[index]);
|
||||
widths[index] = line_width(&lines[index]);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
after.splice(start..old_end, lines[start..new_end].iter().cloned());
|
||||
widths.splice(
|
||||
start..old_end,
|
||||
lines[start..new_end].iter().map(|line| line_width(line)),
|
||||
);
|
||||
}
|
||||
let added_max = widths[start..new_end].iter().copied().max().unwrap_or(0);
|
||||
*max_width = if removed_max == *max_width && added_max < removed_max {
|
||||
widths.iter().copied().max().unwrap_or(0)
|
||||
} else {
|
||||
(*max_width).max(added_max)
|
||||
};
|
||||
}
|
||||
|
||||
fn live_content(lines: &[String], eof: bool) -> &[String] {
|
||||
if eof || (lines.len() == 1 && lines[0].is_empty()) {
|
||||
&lines[..lines.len().saturating_sub(1)]
|
||||
@@ -1133,7 +1435,8 @@ pub(crate) fn line_hunks(
|
||||
}
|
||||
|
||||
fn file_is_changed(file: &ChangedFile) -> bool {
|
||||
file.status != FileStatus::Modified
|
||||
!file.loaded
|
||||
|| file.status != FileStatus::Modified
|
||||
|| !file.hunks.is_empty()
|
||||
|| file.notice.as_ref().is_some_and(|notice| {
|
||||
notice
|
||||
@@ -1182,6 +1485,9 @@ fn update_eof_notice(file: &mut ChangedFile) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::editor::{Editor, Effect, Mode};
|
||||
std::thread_local! {
|
||||
pub(super) static WIDTH_SCANS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
fn excerpt_state(len: usize, changed: &[usize]) -> DiffState {
|
||||
let mut state = DiffState::new();
|
||||
@@ -1496,6 +1802,7 @@ mod tests {
|
||||
path: "renamed.txt".into(),
|
||||
old_path: Some("old.txt".into()),
|
||||
status: FileStatus::Renamed,
|
||||
loaded: true,
|
||||
before: vec![
|
||||
"same".into(),
|
||||
"old".into(),
|
||||
@@ -1532,6 +1839,7 @@ mod tests {
|
||||
path: "added.txt".into(),
|
||||
old_path: None,
|
||||
status: FileStatus::Added,
|
||||
loaded: true,
|
||||
before: vec![],
|
||||
after: vec!["a".into(), "b".into()],
|
||||
before_eof: false,
|
||||
@@ -1548,6 +1856,7 @@ mod tests {
|
||||
path: "removed.bin".into(),
|
||||
old_path: None,
|
||||
status: FileStatus::Removed,
|
||||
loaded: true,
|
||||
before: vec![],
|
||||
after: vec![],
|
||||
before_eof: false,
|
||||
@@ -1983,17 +2292,21 @@ mod tests {
|
||||
assert_eq!(editor.diff().unwrap().focus, Focus::Before);
|
||||
editor.handle(Input::Down);
|
||||
editor.handle(Input::Right);
|
||||
assert_eq!(editor.diff().unwrap().top, 1);
|
||||
assert_eq!(editor.diff().unwrap().left_before, 1);
|
||||
assert_eq!(editor.cursor().row, 1);
|
||||
assert_eq!(editor.cursor().column, 1);
|
||||
editor.scroll_diff(Focus::Before, true);
|
||||
editor.set_diff_viewport(id, 0, 1, 1, 0);
|
||||
editor.handle(Input::Char('\t'));
|
||||
assert_eq!(editor.diff().unwrap().focus, Focus::After);
|
||||
editor.handle(Input::Ctrl('j'));
|
||||
editor.handle(Input::Ctrl('l'));
|
||||
assert_eq!(editor.diff().unwrap().top, 2);
|
||||
editor.handle(Input::Down);
|
||||
editor.handle(Input::Right);
|
||||
assert_eq!(editor.cursor().row, 2);
|
||||
assert_eq!(editor.diff().unwrap().top, 1);
|
||||
editor.set_diff_viewport(id, 0, 2, 1, 1);
|
||||
assert_eq!(editor.diff().unwrap().left_after, 1);
|
||||
assert_eq!(editor.diff().unwrap().left_before, 1);
|
||||
editor.handle(Input::End);
|
||||
assert_eq!(editor.diff().unwrap().top, 5);
|
||||
assert_eq!(editor.diff().unwrap().top, 2);
|
||||
editor.handle(Input::BackTab);
|
||||
assert_eq!(editor.diff().unwrap().focus, Focus::Before);
|
||||
editor.handle(Input::BackTab);
|
||||
@@ -2043,6 +2356,95 @@ mod tests {
|
||||
assert_eq!(editor.diff().unwrap().comparison_id, Some(second));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_diff_rejects_pending_file_details_even_after_reopening() {
|
||||
let mut editor = editor();
|
||||
open(&mut editor);
|
||||
let comparison_id = choose_pair(&mut editor);
|
||||
let mut catalog = comparison();
|
||||
let detail = catalog.files.remove(0);
|
||||
catalog.files.clear();
|
||||
catalog.files.push(ChangedFile {
|
||||
path: detail.path.clone(),
|
||||
old_path: detail.old_path.clone(),
|
||||
status: detail.status,
|
||||
loaded: false,
|
||||
before: vec![],
|
||||
after: vec![],
|
||||
before_eof: false,
|
||||
after_eof: false,
|
||||
hunks: vec![],
|
||||
notice: None,
|
||||
});
|
||||
editor.apply_diff_result(comparison_id, Ok(Outcome::Comparison(catalog)));
|
||||
let (file_request, _) = editor.take_diff_request().unwrap();
|
||||
editor.execute("diff.close");
|
||||
assert!(!editor.apply_diff_result(
|
||||
file_request,
|
||||
Ok(Outcome::File {
|
||||
comparison_id,
|
||||
file_index: 0,
|
||||
file: detail,
|
||||
})
|
||||
));
|
||||
assert!(editor.diff().is_none());
|
||||
open(&mut editor);
|
||||
assert!(editor.diff().unwrap().comparison.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_remote_ref_loader_preserves_selector_position_without_choosing_revision() {
|
||||
let mut state = DiffState::new();
|
||||
let mut initial = catalog();
|
||||
let loader = initial
|
||||
.entries
|
||||
.iter_mut()
|
||||
.find(|entry| matches!(entry.target, RevisionTarget::FetchRemote(_)))
|
||||
.unwrap();
|
||||
loader.target = RevisionTarget::ListRemote("origin".into());
|
||||
loader.label = "Load refs from origin".into();
|
||||
state.apply_result(1, Ok(Outcome::Catalog(initial)));
|
||||
state.phase = Phase::After;
|
||||
state.query = "origin".into();
|
||||
state.refilter();
|
||||
state.selected = 1;
|
||||
state.choose();
|
||||
assert!(
|
||||
matches!(state.take_request(), Some(Operation::ListRemote(remote)) if remote == "origin")
|
||||
);
|
||||
let mut loaded = catalog();
|
||||
let loader = loaded
|
||||
.entries
|
||||
.iter_mut()
|
||||
.find(|entry| matches!(entry.target, RevisionTarget::FetchRemote(_)))
|
||||
.unwrap();
|
||||
loader.target = RevisionTarget::ListRemote("origin".into());
|
||||
loader.label = "Load refs from origin".into();
|
||||
loaded.entries.push(revision(
|
||||
RevisionTarget::Remote {
|
||||
remote: "origin".into(),
|
||||
reference: "refs/heads/new".into(),
|
||||
},
|
||||
"new@origin",
|
||||
"",
|
||||
Category::Branches,
|
||||
));
|
||||
state.apply_result(2, Ok(Outcome::Catalog(loaded)));
|
||||
assert_eq!(state.phase, Phase::After);
|
||||
assert_eq!(state.query, "origin");
|
||||
assert_eq!(state.category, 0);
|
||||
assert_eq!(state.selected, 1);
|
||||
assert!(state.after.is_none());
|
||||
assert!(!state.loading);
|
||||
state.category = 1;
|
||||
state.refilter();
|
||||
assert!(
|
||||
state
|
||||
.entries()
|
||||
.any(|entry| matches!(entry.target, RevisionTarget::Remote { .. }))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovery_and_fetch_errors_are_retryable_and_stale_responses_cannot_reopen() {
|
||||
let mut editor = editor();
|
||||
@@ -2136,12 +2538,12 @@ mod tests {
|
||||
] {
|
||||
editor.handle(input);
|
||||
}
|
||||
assert_eq!(editor.context().text, original);
|
||||
assert_eq!(editor.carets(), carets);
|
||||
editor.handle(Input::Escape);
|
||||
editor.handle(Input::Escape);
|
||||
assert!(editor.diff().is_some());
|
||||
editor.execute("diff.close");
|
||||
assert_eq!(editor.context().text, original);
|
||||
assert_eq!(editor.carets(), carets);
|
||||
editor.handle(Input::Char('u'));
|
||||
assert_eq!(editor.context().text, "original\\nsecond");
|
||||
}
|
||||
@@ -2218,6 +2620,7 @@ mod tests {
|
||||
before_eof: eof,
|
||||
after_eof: eof,
|
||||
notice: None,
|
||||
loaded: true,
|
||||
}],
|
||||
});
|
||||
state.files = Some(FilePicker::from_paths(
|
||||
@@ -2228,6 +2631,147 @@ mod tests {
|
||||
state
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reverting_loaded_file_keeps_unloaded_changes_selectable() {
|
||||
let mut state = live_state(vec!["base".into()], vec!["changed".into()], false);
|
||||
state.comparison_id = Some(17);
|
||||
state.pending = None;
|
||||
state.comparison.as_mut().unwrap().files.push(ChangedFile {
|
||||
path: "unloaded.txt".into(),
|
||||
old_path: None,
|
||||
status: FileStatus::Modified,
|
||||
loaded: false,
|
||||
before: vec![],
|
||||
after: vec![],
|
||||
before_eof: false,
|
||||
after_eof: false,
|
||||
hunks: vec![],
|
||||
notice: None,
|
||||
});
|
||||
state.refresh_after(&["base".into()], 1);
|
||||
assert_eq!(
|
||||
state
|
||||
.files
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.entries()
|
||||
.map(|entry| entry.path)
|
||||
.collect::<Vec<_>>(),
|
||||
[Path::new("unloaded.txt")],
|
||||
);
|
||||
assert!(state.select_file(Path::new("unloaded.txt")));
|
||||
assert!(matches!(
|
||||
state.take_request(),
|
||||
Some(Operation::LoadFile {
|
||||
comparison_id: 17,
|
||||
file_index: 1
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_width_cache_scans_only_changed_lines_and_shrinks_after_deletion() {
|
||||
let base = vec!["original".into(), "medium".into(), "short".into()];
|
||||
let mut lines = vec!["界\tz".into(), "medium".into(), "short".into()];
|
||||
let mut state = live_state(base, lines.clone(), false);
|
||||
state.refresh_after(&lines, 1);
|
||||
assert_eq!(state.after_widths, [5, 6, 5]);
|
||||
lines[0] = "界界\t🙂".into();
|
||||
WIDTH_SCANS.set(0);
|
||||
state.refresh_after(&lines, 2);
|
||||
assert_eq!(WIDTH_SCANS.get(), 1);
|
||||
assert_eq!(state.max_after, 10);
|
||||
state.left_after = 9;
|
||||
lines[0] = "界".into();
|
||||
WIDTH_SCANS.set(0);
|
||||
state.refresh_after(&lines, 3);
|
||||
assert_eq!(WIDTH_SCANS.get(), 1);
|
||||
assert_eq!(state.max_after, 6);
|
||||
assert_eq!(state.left_after, 6);
|
||||
lines.remove(1);
|
||||
WIDTH_SCANS.set(0);
|
||||
state.refresh_after(&lines, 4);
|
||||
assert_eq!(WIDTH_SCANS.get(), 0);
|
||||
assert_eq!(state.after_widths, [2, 5]);
|
||||
assert_eq!(state.max_after, 5);
|
||||
lines.insert(1, "巨大\t🙂".into());
|
||||
WIDTH_SCANS.set(0);
|
||||
state.refresh_after(&lines, 5);
|
||||
assert_eq!(WIDTH_SCANS.get(), 1);
|
||||
assert_eq!(state.after_widths, [2, 10, 5]);
|
||||
lines = vec!["".into()];
|
||||
state.refresh_after(&lines, 6);
|
||||
assert_eq!(state.after_widths, [0]);
|
||||
assert_eq!(state.max_after, 0);
|
||||
assert_eq!(state.left_after, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stable_line_edits_keep_fresh_hunks_expansion_and_scroll_anchor_until_revert() {
|
||||
let base = (0..100)
|
||||
.map(|row| format!("line {row}"))
|
||||
.collect::<Vec<_>>();
|
||||
let mut lines = base.clone();
|
||||
lines[30] = "first change".into();
|
||||
let mut state = live_state(base.clone(), lines.clone(), true);
|
||||
state.set_context_options(2, 5);
|
||||
lines.push(String::new());
|
||||
state.refresh_after(&lines, 1);
|
||||
state.top = state.after_display_row(30).unwrap();
|
||||
state.handle(Input::Char('['));
|
||||
let display = state.display_rows.clone();
|
||||
let excerpts = state.excerpt_ranges.clone();
|
||||
let top = state.top;
|
||||
lines[30].push('!');
|
||||
let file = &state.comparison.as_ref().unwrap().files[0];
|
||||
assert!(stable_after_edit(
|
||||
file,
|
||||
&lines,
|
||||
changed_line_range(&file.after, &lines)
|
||||
));
|
||||
WIDTH_SCANS.set(0);
|
||||
state.refresh_after(&lines, 2);
|
||||
assert_eq!(WIDTH_SCANS.get(), 1);
|
||||
assert_eq!(state.display_rows, display);
|
||||
assert_eq!(state.excerpt_ranges, excerpts);
|
||||
assert_eq!(state.top, top);
|
||||
let file = &state.comparison.as_ref().unwrap().files[0];
|
||||
let hunk_ranges = |hunks: &[Hunk]| {
|
||||
hunks
|
||||
.iter()
|
||||
.map(|hunk| {
|
||||
(
|
||||
hunk.before_start,
|
||||
hunk.before_len,
|
||||
hunk.after_start,
|
||||
hunk.after_len,
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(
|
||||
hunk_ranges(&file.hunks),
|
||||
hunk_ranges(&line_hunks(&base, true, live_content(&lines, true), true))
|
||||
);
|
||||
lines[30].clone_from(&base[30]);
|
||||
assert!(!stable_after_edit(
|
||||
file,
|
||||
&lines,
|
||||
changed_line_range(&file.after, &lines)
|
||||
));
|
||||
state.refresh_after(&lines, 3);
|
||||
assert!(state.comparison.as_ref().unwrap().files[0].hunks.is_empty());
|
||||
assert!(state.rows.iter().all(|row| row.kind == RowKind::Equal));
|
||||
assert!(state.after_display_row(23).is_some());
|
||||
assert_eq!(state.top, state.after_display_row(30).unwrap());
|
||||
assert_eq!(state.files.as_ref().unwrap().entries().len(), 0);
|
||||
lines[50] = "changed again".into();
|
||||
state.refresh_after(&lines, 4);
|
||||
assert_eq!(state.files.as_ref().unwrap().entries().len(), 1);
|
||||
assert!(state.after_display_row(23).is_some());
|
||||
assert!(state.after_display_row(50).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_reflow_keeps_separate_hunks_and_expanded_context_after_revert() {
|
||||
let base = (0..100)
|
||||
@@ -2307,4 +2851,102 @@ mod tests {
|
||||
empty.handle(Input::Escape);
|
||||
assert_eq!(empty.phase, Phase::View);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lazy_selection_waits_for_details_rejects_stale_files_and_reuses_loaded_cache() {
|
||||
let mut state = DiffState::new();
|
||||
state.pending = None;
|
||||
state.request_kind = Some(RequestKind::Comparison);
|
||||
let mut catalog = comparison();
|
||||
catalog.files.truncate(2);
|
||||
let details: Vec<_> = catalog.files.drain(..).collect();
|
||||
for detail in &details {
|
||||
catalog.files.push(ChangedFile {
|
||||
path: detail.path.clone(),
|
||||
old_path: detail.old_path.clone(),
|
||||
status: detail.status,
|
||||
loaded: false,
|
||||
before: vec![],
|
||||
after: vec![],
|
||||
before_eof: false,
|
||||
after_eof: false,
|
||||
hunks: vec![],
|
||||
notice: None,
|
||||
});
|
||||
}
|
||||
state.apply_result(41, Ok(Outcome::Comparison(catalog)));
|
||||
assert!(!state.selected_file_loaded());
|
||||
assert!(state.rows.is_empty() && state.display_rows.is_empty());
|
||||
assert_eq!(state.pane_layout(), PaneLayout::Empty);
|
||||
assert!(!state.after_editable());
|
||||
assert!(matches!(
|
||||
state.take_request(),
|
||||
Some(Operation::LoadFile {
|
||||
comparison_id: 41,
|
||||
file_index: 0,
|
||||
})
|
||||
));
|
||||
assert!(state.select_file(Path::new("added.txt")));
|
||||
assert!(matches!(
|
||||
state.take_request(),
|
||||
Some(Operation::LoadFile {
|
||||
comparison_id: 41,
|
||||
file_index: 1,
|
||||
})
|
||||
));
|
||||
let mut details = details.into_iter();
|
||||
let first = details.next().unwrap();
|
||||
let second = details.next().unwrap();
|
||||
state.apply_result(
|
||||
42,
|
||||
Ok(Outcome::File {
|
||||
comparison_id: 40,
|
||||
file_index: 1,
|
||||
file: first,
|
||||
}),
|
||||
);
|
||||
assert!(state.loading && !state.selected_file_loaded());
|
||||
state.apply_result(
|
||||
43,
|
||||
Ok(Outcome::File {
|
||||
comparison_id: 41,
|
||||
file_index: 1,
|
||||
file: second,
|
||||
}),
|
||||
);
|
||||
assert!(state.selected_file_loaded());
|
||||
assert_eq!(state.pane_layout(), PaneLayout::After);
|
||||
assert!(!state.rows.is_empty());
|
||||
assert!(state.select_file(Path::new("renamed.txt")));
|
||||
assert!(state.loading);
|
||||
assert!(state.select_file(Path::new("added.txt")));
|
||||
assert!(!state.loading && state.selected_file_loaded());
|
||||
assert!(state.take_request().is_none());
|
||||
state.apply_result(44, Err("cancelled old load".into()));
|
||||
assert!(state.error.is_none() && state.selected_file_loaded());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_lazy_file_load_can_be_retried_without_reopening_comparison() {
|
||||
let mut state = DiffState::new();
|
||||
state.pending = None;
|
||||
state.request_kind = Some(RequestKind::Comparison);
|
||||
let mut catalog = comparison();
|
||||
catalog.files.truncate(1);
|
||||
catalog.files[0].loaded = false;
|
||||
state.apply_result(7, Ok(Outcome::Comparison(catalog)));
|
||||
state.take_request();
|
||||
state.apply_result(8, Err("cannot read blob".into()));
|
||||
assert_eq!(state.error.as_deref(), Some("cannot read blob"));
|
||||
assert!(!state.selected_file_loaded());
|
||||
assert!(state.select_file(Path::new("renamed.txt")));
|
||||
assert!(state.loading && state.error.is_none());
|
||||
assert!(matches!(
|
||||
state.take_request(),
|
||||
Some(Operation::LoadFile {
|
||||
comparison_id: 7,
|
||||
file_index: 0,
|
||||
})
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
+1400
-210
File diff suppressed because it is too large
Load Diff
@@ -12,3 +12,4 @@ pub mod session;
|
||||
mod snippet;
|
||||
pub mod syntax;
|
||||
pub mod tui;
|
||||
mod wrap;
|
||||
|
||||
@@ -17,6 +17,7 @@ Files: Space o floating tree INSERT; Space e docked tree NORMAL (right by defaul
|
||||
Normal: i/a insert, I/A insert at first nonblank/end, o/O new line
|
||||
h/j/k/l move, 0/$ line ends, x delete, J join lines
|
||||
w/b/e word next/previous/end, W/B/E whitespace-delimited WORDs
|
||||
Normal/Visual: f<char>/F<char> seek next/previous on current line; 2fx seeks second x
|
||||
gf follows Markdown file links/reference links and heading fragments (no LSP)
|
||||
Normal/Visual: gt first line, gb last line, at first nonblank; Esc cancels g
|
||||
Ctrl+o older jump location; Ctrl+i/Tab newer (counts supported)
|
||||
@@ -44,6 +45,8 @@ Buffers: :edit PATH, :buffers, :buffer N, :buffer_next, :buffer_previous, :buffe
|
||||
Markdown links: :follow_link (primary caret; local files only)
|
||||
Diff: :diff (Normal); choose Before then After; Working checkout After edits live unsaved buffers
|
||||
Before and historical/remote After stay read-only; Tab/Shift+Tab categories/Normal focus
|
||||
All text panes use native motions, selections and copy; read-only panes reject edits/saves
|
||||
Switching Before/After preserves the aligned cursor row and display column
|
||||
Type to search IDs/refs; navigation skips omitted lines without expanding excerpts
|
||||
Normal [/] explicitly expands context; Insert brackets remain literal text
|
||||
:diff.close exits explicitly; Esc cancels modes/prompts/filtering without closing the diff
|
||||
|
||||
+297
-122
@@ -297,10 +297,18 @@ struct Remote {
|
||||
struct FileData {
|
||||
mode: String,
|
||||
bytes: Vec<u8>,
|
||||
object: Option<(PathBuf, String)>,
|
||||
notice: Option<String>,
|
||||
}
|
||||
type Snapshot = BTreeMap<PathBuf, FileData>;
|
||||
|
||||
struct PreparedComparison {
|
||||
id: u64,
|
||||
left: Snapshot,
|
||||
right: Snapshot,
|
||||
files: Vec<(PathBuf, Option<PathBuf>, FileStatus)>,
|
||||
}
|
||||
|
||||
pub struct Repository {
|
||||
root: PathBuf,
|
||||
kind: RepoKind,
|
||||
@@ -311,6 +319,7 @@ pub struct Repository {
|
||||
remote_refs: BTreeMap<(String, String), String>,
|
||||
loaded: BTreeSet<String>,
|
||||
registered: BTreeSet<String>,
|
||||
comparison: Option<PreparedComparison>,
|
||||
cancelled: Arc<AtomicBool>,
|
||||
}
|
||||
impl Repository {
|
||||
@@ -348,6 +357,7 @@ impl Repository {
|
||||
remote_refs: BTreeMap::new(),
|
||||
loaded: BTreeSet::new(),
|
||||
registered: BTreeSet::new(),
|
||||
comparison: None,
|
||||
cancelled,
|
||||
})
|
||||
}
|
||||
@@ -562,42 +572,53 @@ impl Repository {
|
||||
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);
|
||||
for &view in self.kind.categories() {
|
||||
let history = view == category;
|
||||
entries.push(Revision {
|
||||
target: if history {
|
||||
RevisionTarget::FetchRemote(remote.name.clone())
|
||||
} else {
|
||||
RevisionTarget::ListRemote(remote.name.clone())
|
||||
},
|
||||
label: format!(
|
||||
"Load {} from {}",
|
||||
if history { "history" } else { "refs" },
|
||||
remote.name
|
||||
),
|
||||
description: if history {
|
||||
"Fetch reachable history into a private comparison repository"
|
||||
} else {
|
||||
"List remote references without fetching history"
|
||||
}
|
||||
.into(),
|
||||
category: view,
|
||||
});
|
||||
}
|
||||
for ((name, reference), id) in &self.remote_refs {
|
||||
if name != &remote.name {
|
||||
continue;
|
||||
}
|
||||
let tag = reference.starts_with("refs/tags/");
|
||||
let label = reference
|
||||
.strip_prefix(if tag { "refs/tags/" } else { "refs/heads/" })
|
||||
.unwrap();
|
||||
if !tag || self.kind == RepoKind::Git {
|
||||
entries.push(Revision {
|
||||
target: RevisionTarget::Remote {
|
||||
remote: name.clone(),
|
||||
reference: reference.clone(),
|
||||
},
|
||||
label: format!("{label}@{name}"),
|
||||
description: id.clone(),
|
||||
category: if tag {
|
||||
Category::Tags
|
||||
} else if self.kind == RepoKind::Jujutsu {
|
||||
Category::Bookmarks
|
||||
} else {
|
||||
Category::Branches
|
||||
},
|
||||
});
|
||||
}
|
||||
Err(e) => notices.push(format!("{}: {e}", remote.name)),
|
||||
}
|
||||
}
|
||||
self.check_cancelled()?;
|
||||
@@ -645,7 +666,7 @@ impl Repository {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn load_remote(&mut self, name: &str) -> Result<(), String> {
|
||||
fn load_remote(&mut self, name: &str, reference: Option<&str>) -> Result<(), String> {
|
||||
if self.remotes.is_empty() {
|
||||
self.discover_remotes()?;
|
||||
}
|
||||
@@ -674,7 +695,27 @@ impl Repository {
|
||||
"--remote",
|
||||
&format!("exact:{name}"),
|
||||
"--branch",
|
||||
"glob:*",
|
||||
&reference.map_or_else(
|
||||
|| Ok("glob:*".to_owned()),
|
||||
|reference| {
|
||||
reference
|
||||
.strip_prefix("refs/heads/")
|
||||
.map(|branch| format!("exact:{branch}"))
|
||||
.ok_or("Jujutsu remote selection requires a bookmark")
|
||||
},
|
||||
)?,
|
||||
],
|
||||
)?;
|
||||
} else if let Some(reference) = reference {
|
||||
self.git(
|
||||
store,
|
||||
&[
|
||||
"fetch",
|
||||
"--no-tags",
|
||||
"--no-write-fetch-head",
|
||||
"--",
|
||||
&url,
|
||||
&format!("+{reference}:refs/vamp-selected/{name}/{reference}"),
|
||||
],
|
||||
)?;
|
||||
} else {
|
||||
@@ -690,16 +731,33 @@ impl Repository {
|
||||
],
|
||||
)?;
|
||||
}
|
||||
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);
|
||||
if reference.is_none() {
|
||||
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 list_remote(&mut self, name: &str) -> Result<Catalog, String> {
|
||||
self.discover_remotes()?;
|
||||
let remote = self
|
||||
.remotes
|
||||
.iter()
|
||||
.find(|remote| remote.name == name)
|
||||
.ok_or_else(|| format!("Unknown configured remote: {name}"))?;
|
||||
let refs = self.advertised(&remote.url)?;
|
||||
self.remote_refs.retain(|(remote, _), _| remote != name);
|
||||
self.remote_refs.extend(
|
||||
refs.into_iter()
|
||||
.map(|(reference, id)| ((name.into(), reference), id)),
|
||||
);
|
||||
self.catalog()
|
||||
}
|
||||
pub fn fetch_remote(&mut self, remote: &str) -> Result<Catalog, String> {
|
||||
self.load_remote(remote)?;
|
||||
self.load_remote(remote, None)?;
|
||||
self.catalog()
|
||||
}
|
||||
fn resolve(&mut self, target: &RevisionTarget) -> Result<(PathBuf, String), String> {
|
||||
@@ -719,22 +777,33 @@ impl Repository {
|
||||
}
|
||||
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::Remote { remote, reference } => 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(_) => {
|
||||
RevisionTarget::FetchRemote(_) | RevisionTarget::ListRemote(_) => {
|
||||
return Err("Select a revision, not a history loader".into());
|
||||
}
|
||||
};
|
||||
if let Some(resolved) = self.resolve_stored(&id)? {
|
||||
return Ok(resolved);
|
||||
}
|
||||
if let RevisionTarget::Remote { remote, reference } = target {
|
||||
self.load_remote(remote, Some(reference))?;
|
||||
if let Some(resolved) = self.resolve_stored(&id)? {
|
||||
return Ok(resolved);
|
||||
}
|
||||
}
|
||||
Err(format!(
|
||||
"Commit {id} is unavailable locally or in loaded remote history"
|
||||
))
|
||||
}
|
||||
|
||||
fn resolve_stored(&self, id: &str) -> Result<Option<(PathBuf, String)>, String> {
|
||||
for store in
|
||||
std::iter::once(&self.store).chain(self.private.as_ref().map(|(_, store)| store))
|
||||
{
|
||||
@@ -743,15 +812,13 @@ impl Repository {
|
||||
.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()));
|
||||
return Ok(Some((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"
|
||||
))
|
||||
Ok(None)
|
||||
}
|
||||
fn conflicted_snapshot(&self, root: &Path, id: &str) -> Result<Snapshot, String> {
|
||||
let revset = format!("commit_id({})", serde_json::to_string(id).unwrap());
|
||||
@@ -820,6 +887,7 @@ impl Repository {
|
||||
FileData {
|
||||
mode: mode.into(),
|
||||
bytes,
|
||||
object: None,
|
||||
notice,
|
||||
},
|
||||
);
|
||||
@@ -853,8 +921,7 @@ impl Repository {
|
||||
}
|
||||
}
|
||||
let output = self.git(store, &["ls-tree", "-r", "-z", "--full-tree", id])?;
|
||||
let mut entries = vec![];
|
||||
let mut request = Vec::new();
|
||||
let mut snapshot = BTreeMap::new();
|
||||
for record in output.split(|b| *b == 0).filter(|r| !r.is_empty()) {
|
||||
let tab = record
|
||||
.iter()
|
||||
@@ -868,32 +935,60 @@ impl Repository {
|
||||
}
|
||||
let path = path_bytes(&record[tab + 1..])?;
|
||||
safe_path(&path)?;
|
||||
if fields[1] == "commit" {
|
||||
entries.push((
|
||||
path,
|
||||
fields[0].to_owned(),
|
||||
let (object, notice) = if fields[1] == "commit" {
|
||||
(
|
||||
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));
|
||||
(Some((store.to_path_buf(), fields[2].to_owned())), None)
|
||||
} else {
|
||||
return Err(format!("Unsupported tree entry type: {}", fields[1]));
|
||||
}
|
||||
};
|
||||
snapshot.insert(
|
||||
path,
|
||||
FileData {
|
||||
mode: fields[0].into(),
|
||||
bytes: vec![],
|
||||
object,
|
||||
notice,
|
||||
},
|
||||
);
|
||||
}
|
||||
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() {
|
||||
Ok(snapshot)
|
||||
}
|
||||
|
||||
fn materialize(&self, snapshot: &mut Snapshot) -> Result<(), String> {
|
||||
let stores: BTreeSet<_> = snapshot
|
||||
.values()
|
||||
.filter_map(|file| file.object.as_ref().map(|(store, _)| store.clone()))
|
||||
.collect();
|
||||
for store in stores {
|
||||
let mut request = Vec::new();
|
||||
for file in snapshot.values() {
|
||||
if let Some((source, id)) = &file.object
|
||||
&& source == &store
|
||||
{
|
||||
request.extend_from_slice(id.as_bytes());
|
||||
request.push(b'\n');
|
||||
}
|
||||
}
|
||||
let mut cmd = git_command(&store);
|
||||
cmd.args(["cat-file", "--batch"]);
|
||||
let data = checked(run(cmd, request, &self.cancelled)?)?;
|
||||
let mut offset = 0;
|
||||
for (path, file) in snapshot.iter_mut() {
|
||||
if file
|
||||
.object
|
||||
.as_ref()
|
||||
.is_none_or(|(source, _)| source != &store)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
self.check_cancelled()?;
|
||||
let end = offset
|
||||
+ data[offset..]
|
||||
.iter()
|
||||
@@ -917,22 +1012,12 @@ impl Repository {
|
||||
if size >= data.len().saturating_sub(offset) {
|
||||
return Err("Truncated Git object content".into());
|
||||
}
|
||||
let bytes = data[offset..offset + size].to_vec();
|
||||
file.bytes = data[offset..offset + size].to_vec();
|
||||
file.object = None;
|
||||
offset += size + 1;
|
||||
bytes
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
snapshot.insert(
|
||||
path,
|
||||
FileData {
|
||||
mode,
|
||||
bytes,
|
||||
notice,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(snapshot)
|
||||
Ok(())
|
||||
}
|
||||
fn working_snapshot(&self, buffers: &[(PathBuf, String)]) -> Result<Snapshot, String> {
|
||||
let mut tracked = BTreeSet::new();
|
||||
@@ -1100,6 +1185,7 @@ impl Repository {
|
||||
FileData {
|
||||
mode: "160000".into(),
|
||||
bytes: vec![],
|
||||
object: None,
|
||||
notice: Some(notice),
|
||||
},
|
||||
);
|
||||
@@ -1178,6 +1264,7 @@ impl Repository {
|
||||
FileData {
|
||||
mode,
|
||||
bytes,
|
||||
object: None,
|
||||
notice,
|
||||
},
|
||||
);
|
||||
@@ -1192,38 +1279,72 @@ impl Repository {
|
||||
self.commit_snapshot(&store, &id)
|
||||
}
|
||||
}
|
||||
/// Freeze comparison sources and return path/status metadata only.
|
||||
/// A new comparison replaces sources retained for the previous request ID.
|
||||
pub fn compare(
|
||||
&mut self,
|
||||
comparison_id: u64,
|
||||
before: &Revision,
|
||||
after: &Revision,
|
||||
buffers: &[(PathBuf, String)],
|
||||
) -> Result<Comparison, String> {
|
||||
let left = self.snapshot(&before.target)?;
|
||||
self.comparison = None;
|
||||
let mut left = self.snapshot(&before.target)?;
|
||||
let working_after = after.target == RevisionTarget::Working;
|
||||
let right = if before.target == after.target && (!working_after || buffers.is_empty()) {
|
||||
None
|
||||
let mut right = if before.target == after.target && (!working_after || buffers.is_empty()) {
|
||||
BTreeMap::new()
|
||||
} else if working_after {
|
||||
Some(self.working_snapshot(buffers)?)
|
||||
self.working_snapshot(buffers)?
|
||||
} else {
|
||||
Some(self.snapshot(&after.target)?)
|
||||
self.snapshot(&after.target)?
|
||||
};
|
||||
let right = right.as_ref().unwrap_or(&left);
|
||||
let same = before.target == after.target && (!working_after || buffers.is_empty());
|
||||
// Frozen checkout/conflict bytes have no object identity. Compare them in a
|
||||
// single batch, while immutable tree/tree comparisons need only object IDs.
|
||||
let frozen = |file: &FileData| {
|
||||
file.object.is_none() && matches!(file.mode.as_str(), "100644" | "100755" | "120000")
|
||||
};
|
||||
if !same && (left.values().any(frozen) || right.values().any(frozen)) {
|
||||
self.materialize(&mut left)?;
|
||||
self.materialize(&mut right)?;
|
||||
}
|
||||
let right_view = if same { &left } else { &right };
|
||||
let mut files = vec![];
|
||||
let paths: BTreeSet<_> = left.keys().chain(right.keys()).collect();
|
||||
let paths: BTreeSet<_> = left.keys().chain(right_view.keys()).collect();
|
||||
for path in paths {
|
||||
self.check_cancelled()?;
|
||||
let a = left.get(path);
|
||||
let b = right.get(path);
|
||||
if a == b {
|
||||
let b = right_view.get(path);
|
||||
if a.map(file_identity) == b.map(file_identity) {
|
||||
continue;
|
||||
}
|
||||
files.push(self.changed_file(path, a, b)?);
|
||||
files.push(ChangedFile {
|
||||
path: path.clone(),
|
||||
old_path: None,
|
||||
status: if a.is_none() {
|
||||
FileStatus::Added
|
||||
} else if b.is_none() {
|
||||
FileStatus::Removed
|
||||
} else {
|
||||
FileStatus::Modified
|
||||
},
|
||||
loaded: false,
|
||||
before: vec![],
|
||||
after: vec![],
|
||||
before_eof: false,
|
||||
after_eof: false,
|
||||
hunks: vec![],
|
||||
notice: None,
|
||||
});
|
||||
}
|
||||
// Match only identical data; modified-content moves remain add/remove pairs.
|
||||
let mut candidates: BTreeMap<&FileData, Vec<usize>> = BTreeMap::new();
|
||||
let mut candidates = BTreeMap::new();
|
||||
for (i, file) in files.iter().enumerate() {
|
||||
if file.status == FileStatus::Removed {
|
||||
candidates.entry(&left[&file.path]).or_default().push(i);
|
||||
candidates
|
||||
.entry(file_identity(&left[&file.path]))
|
||||
.or_insert_with(Vec::new)
|
||||
.push(i);
|
||||
}
|
||||
}
|
||||
let mut removed = BTreeSet::new();
|
||||
@@ -1232,18 +1353,11 @@ impl Repository {
|
||||
continue;
|
||||
}
|
||||
if let Some(j) = candidates
|
||||
.get_mut(&right[&files[i].path])
|
||||
.get_mut(&file_identity(&right_view[&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;
|
||||
files[i].status = FileStatus::Renamed;
|
||||
files[i].old_path = Some(files[j].path.clone());
|
||||
removed.insert(j);
|
||||
}
|
||||
}
|
||||
@@ -1256,21 +1370,24 @@ impl Repository {
|
||||
files
|
||||
.iter()
|
||||
.filter(|file| {
|
||||
right.get(&file.path).is_some_and(|data| {
|
||||
matches!(data.mode.as_str(), "100644" | "100755")
|
||||
&& !data.bytes.contains(&0)
|
||||
&& std::str::from_utf8(&data.bytes).is_ok()
|
||||
&& file
|
||||
.notice
|
||||
.as_deref()
|
||||
.is_none_or(|notice| !notice.contains("Binary or non-UTF-8"))
|
||||
})
|
||||
right_view
|
||||
.get(&file.path)
|
||||
.is_some_and(|data| matches!(data.mode.as_str(), "100644" | "100755"))
|
||||
})
|
||||
.map(|file| file.path.clone())
|
||||
.collect()
|
||||
} else {
|
||||
BTreeSet::new()
|
||||
};
|
||||
self.comparison = Some(PreparedComparison {
|
||||
id: comparison_id,
|
||||
left,
|
||||
right,
|
||||
files: files
|
||||
.iter()
|
||||
.map(|file| (file.path.clone(), file.old_path.clone(), file.status))
|
||||
.collect(),
|
||||
});
|
||||
Ok(Comparison {
|
||||
before: before.label.clone(),
|
||||
after: after.label.clone(),
|
||||
@@ -1279,6 +1396,45 @@ impl Repository {
|
||||
editable_paths,
|
||||
})
|
||||
}
|
||||
|
||||
/// Prepare one catalog entry using its original comparison's frozen sources.
|
||||
pub fn load_file(
|
||||
&mut self,
|
||||
comparison_id: u64,
|
||||
file_index: usize,
|
||||
) -> Result<ChangedFile, String> {
|
||||
self.check_cancelled()?;
|
||||
let prepared = self
|
||||
.comparison
|
||||
.as_ref()
|
||||
.filter(|comparison| comparison.id == comparison_id)
|
||||
.ok_or("Comparison is no longer available")?;
|
||||
let (path, old_path, status) = prepared
|
||||
.files
|
||||
.get(file_index)
|
||||
.ok_or("Changed file is no longer available")?;
|
||||
let before_path = old_path.as_ref().unwrap_or(path);
|
||||
let mut left = BTreeMap::new();
|
||||
let mut right = BTreeMap::new();
|
||||
let before = prepared.left.get(before_path);
|
||||
let after = prepared.right.get(path);
|
||||
if let Some(data) = before.filter(|data| data.object.is_some()) {
|
||||
left.insert(before_path.clone(), clone_file_data(data));
|
||||
}
|
||||
if let Some(data) = after.filter(|data| data.object.is_some()) {
|
||||
right.insert(path.clone(), clone_file_data(data));
|
||||
}
|
||||
self.materialize(&mut left)?;
|
||||
self.materialize(&mut right)?;
|
||||
let mut file = self.changed_file(
|
||||
path,
|
||||
left.get(before_path).or(before),
|
||||
right.get(path).or(after),
|
||||
)?;
|
||||
file.status = *status;
|
||||
file.old_path = old_path.clone();
|
||||
Ok(file)
|
||||
}
|
||||
fn changed_file(
|
||||
&self,
|
||||
path: &Path,
|
||||
@@ -1337,6 +1493,7 @@ impl Repository {
|
||||
path: path.to_path_buf(),
|
||||
old_path: None,
|
||||
status,
|
||||
loaded: true,
|
||||
before: before_lines,
|
||||
after: after_lines,
|
||||
before_eof: a.ends_with(b"\n"),
|
||||
@@ -1350,6 +1507,24 @@ impl Repository {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn file_identity(file: &FileData) -> (&str, Option<&str>, &[u8], Option<&str>) {
|
||||
(
|
||||
&file.mode,
|
||||
file.object.as_ref().map(|(_, id)| id.as_str()),
|
||||
&file.bytes,
|
||||
file.notice.as_deref(),
|
||||
)
|
||||
}
|
||||
|
||||
fn clone_file_data(file: &FileData) -> FileData {
|
||||
FileData {
|
||||
mode: file.mode.clone(),
|
||||
bytes: file.bytes.clone(),
|
||||
object: file.object.clone(),
|
||||
notice: file.notice.clone(),
|
||||
}
|
||||
}
|
||||
fn lines(bytes: &[u8]) -> Vec<String> {
|
||||
if bytes.is_empty() {
|
||||
return vec![];
|
||||
|
||||
@@ -937,12 +937,20 @@ end
|
||||
config.bind "Z", config.core.buffer_force_delete
|
||||
config.bind "R", config.core.diff
|
||||
config.bind "T", config.core.diff.close
|
||||
config.bind "H", config.core.word_wrap.toggle
|
||||
raise "mutable diff actions" unless config.core.frozen? && config.core.diff.frozen? &&
|
||||
config.core.diff.close.frozen? && config.core.diff.name.frozen? &&
|
||||
config.core.diff.close.name.frozen?
|
||||
end"#,
|
||||
);
|
||||
let mut review = EditorSession::open(None, vec!["text".into()], Some(&config)).unwrap();
|
||||
assert!(!review.editor().word_wrap());
|
||||
review.handle(Input::Char('H'));
|
||||
assert!(review.editor().word_wrap());
|
||||
review.execute("word_wrap.toggle unexpected");
|
||||
assert!(review.editor().word_wrap());
|
||||
review.execute("word_wrap.toggle");
|
||||
assert!(!review.editor().word_wrap());
|
||||
review.handle(Input::Char('R'));
|
||||
review.handle(Input::Escape);
|
||||
assert!(review.editor().diff().is_some());
|
||||
|
||||
+48
-23
@@ -5,7 +5,7 @@ use std::{
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, AtomicU64, Ordering},
|
||||
mpsc::{self, Receiver, SyncSender},
|
||||
mpsc::{self, Receiver, Sender},
|
||||
},
|
||||
thread::{self, JoinHandle},
|
||||
};
|
||||
@@ -25,7 +25,7 @@ use crate::{
|
||||
type DiffResult = (u64, Result<DiffOutcome, String>);
|
||||
|
||||
struct DiffWorker {
|
||||
requests: Option<SyncSender<(u64, DiffOperation)>>,
|
||||
requests: Option<Sender<(u64, DiffOperation)>>,
|
||||
results: Option<Receiver<DiffResult>>,
|
||||
cancelled: Arc<AtomicBool>,
|
||||
stopped: Arc<AtomicBool>,
|
||||
@@ -36,7 +36,7 @@ struct DiffWorker {
|
||||
|
||||
impl DiffWorker {
|
||||
fn start(root: PathBuf) -> Result<Self, String> {
|
||||
let (requests, incoming) = mpsc::sync_channel::<(u64, DiffOperation)>(1);
|
||||
let (requests, incoming) = mpsc::channel::<(u64, DiffOperation)>();
|
||||
let (outgoing, results) = mpsc::sync_channel(1);
|
||||
let cancelled = Arc::new(AtomicBool::new(false));
|
||||
let stopped = Arc::new(AtomicBool::new(false));
|
||||
@@ -56,7 +56,7 @@ impl DiffWorker {
|
||||
if stop.load(Ordering::Acquire) {
|
||||
break;
|
||||
}
|
||||
if cancelled_id.load(Ordering::Acquire) == id {
|
||||
if cancelled_id.load(Ordering::Acquire) >= id {
|
||||
continue;
|
||||
}
|
||||
let result = (|| {
|
||||
@@ -68,6 +68,9 @@ impl DiffWorker {
|
||||
DiffOperation::Discover => {
|
||||
repository.catalog().map(DiffOutcome::Catalog)
|
||||
}
|
||||
DiffOperation::ListRemote(remote) => {
|
||||
repository.list_remote(&remote).map(DiffOutcome::Catalog)
|
||||
}
|
||||
DiffOperation::FetchRemote(remote) => {
|
||||
repository.fetch_remote(&remote).map(DiffOutcome::Catalog)
|
||||
}
|
||||
@@ -76,8 +79,18 @@ impl DiffWorker {
|
||||
after,
|
||||
buffers,
|
||||
} => repository
|
||||
.compare(&before, &after, &buffers)
|
||||
.compare(id, &before, &after, &buffers)
|
||||
.map(DiffOutcome::Comparison),
|
||||
DiffOperation::LoadFile {
|
||||
comparison_id,
|
||||
file_index,
|
||||
} => repository.load_file(comparison_id, file_index).map(|file| {
|
||||
DiffOutcome::File {
|
||||
comparison_id,
|
||||
file_index,
|
||||
file,
|
||||
}
|
||||
}),
|
||||
}
|
||||
})();
|
||||
if stop.load(Ordering::Acquire) || outgoing.send((id, result)).is_err() {
|
||||
@@ -98,16 +111,15 @@ impl DiffWorker {
|
||||
}
|
||||
|
||||
fn request(&mut self, id: u64, operation: DiffOperation) -> Result<(), String> {
|
||||
if let Some(previous) = self.pending.take() {
|
||||
self.cancelled_request.store(previous, Ordering::Release);
|
||||
self.cancelled.store(true, Ordering::Release);
|
||||
}
|
||||
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()
|
||||
}
|
||||
})?;
|
||||
.send((id, operation))
|
||||
.map_err(|_| "Diff worker stopped unexpectedly".to_owned())?;
|
||||
self.pending = Some(id);
|
||||
Ok(())
|
||||
}
|
||||
@@ -263,16 +275,21 @@ impl EditorSession {
|
||||
comparison_id: u64,
|
||||
file: usize,
|
||||
top: usize,
|
||||
left_before: usize,
|
||||
left_after: usize,
|
||||
) {
|
||||
self.editor
|
||||
.set_diff_viewport(comparison_id, file, top, left_after);
|
||||
.set_diff_viewport(comparison_id, file, top, left_before, left_after);
|
||||
}
|
||||
|
||||
pub(crate) fn scroll_diff(&mut self, focus: Focus, down: bool) {
|
||||
self.editor.scroll_diff(focus, down);
|
||||
}
|
||||
|
||||
pub(crate) fn scroll_diff_horizontal(&mut self, focus: Focus, right: bool, width: usize) {
|
||||
self.editor.scroll_diff_horizontal(focus, right, width);
|
||||
}
|
||||
|
||||
pub fn handle(&mut self, input: Input) {
|
||||
self.cancel_pending_popup_request();
|
||||
let invocation = self.editor.handle(input);
|
||||
@@ -405,7 +422,7 @@ impl EditorSession {
|
||||
return;
|
||||
}
|
||||
let context = self.editor.context();
|
||||
let read_only = self.editor.diff().is_some() && !self.editor.diff_editing();
|
||||
let read_only = self.editor.mutation_blocked();
|
||||
let result = match self.plugins.get_mut(&invocation.plugin) {
|
||||
Some(plugin) if plugin.is_async() => {
|
||||
let requests = self.requests.entry(invocation.plugin.clone()).or_default();
|
||||
@@ -483,7 +500,7 @@ impl EditorSession {
|
||||
}
|
||||
let mut events = Vec::new();
|
||||
if !self.editor.should_quit() {
|
||||
for document in self.editor.documents() {
|
||||
for document in self.editor.interaction_documents() {
|
||||
let path = document.path.map(Path::to_string_lossy);
|
||||
let previous = self.documents.get(&document.id);
|
||||
let renamed = previous
|
||||
@@ -576,7 +593,7 @@ impl EditorSession {
|
||||
|
||||
fn source_is_current(&self, source: InvocationSource) -> bool {
|
||||
self.editor
|
||||
.documents()
|
||||
.interaction_documents()
|
||||
.any(|document| document.id == source.buffer_id && document.revision == source.revision)
|
||||
}
|
||||
|
||||
@@ -586,7 +603,7 @@ impl EditorSession {
|
||||
request_id: Option<u64>,
|
||||
source: InvocationSource,
|
||||
) -> bool {
|
||||
if self.editor.diff().is_some() && !self.editor.diff_editing() {
|
||||
if self.editor.diff().is_some() && !self.editor.diff_interacting() {
|
||||
return false;
|
||||
}
|
||||
self.editor.buffer_id() == source.buffer_id
|
||||
@@ -622,6 +639,7 @@ impl EditorSession {
|
||||
|
||||
fn completion_is_current(&self, request_id: Option<u64>, source: InvocationSource) -> bool {
|
||||
self.pending_completion
|
||||
&& !self.editor.mutation_blocked()
|
||||
&& self.request_is_current("core", request_id, source)
|
||||
&& self.editor.mode() == Mode::Insert
|
||||
&& self.editor.palette().is_none()
|
||||
@@ -855,7 +873,7 @@ impl EditorSession {
|
||||
}
|
||||
return Ok(changed);
|
||||
}
|
||||
if self.editor.diff().is_some() && !self.editor.diff_editing() {
|
||||
if self.editor.mutation_blocked() {
|
||||
return Ok(false);
|
||||
}
|
||||
let changed = !effects.is_empty();
|
||||
@@ -3381,6 +3399,7 @@ end
|
||||
path: PathBuf::from("buffer.txt"),
|
||||
old_path: None,
|
||||
status: FileStatus::Modified,
|
||||
loaded: true,
|
||||
before: vec!["base".into()],
|
||||
after: vec!["base".into()],
|
||||
before_eof: false,
|
||||
@@ -3425,29 +3444,35 @@ end
|
||||
.apply_response("fixture", 2, vec![Effect::Insert("stale".into())])
|
||||
.unwrap()
|
||||
);
|
||||
let current = InvocationSource {
|
||||
session.editor.scroll_diff(Focus::Before, false);
|
||||
let readonly = InvocationSource {
|
||||
buffer_id: session.editor.buffer_id(),
|
||||
revision: session.editor.text_revision(),
|
||||
};
|
||||
session.editor.scroll_diff(Focus::Before, false);
|
||||
session
|
||||
.requests
|
||||
.get_mut("fixture")
|
||||
.unwrap()
|
||||
.pending
|
||||
.insert(3, current);
|
||||
.insert(3, readonly);
|
||||
assert!(
|
||||
!session
|
||||
.apply_response("fixture", 3, vec![Effect::Insert("readonly".into())])
|
||||
.unwrap()
|
||||
);
|
||||
assert_eq!(session.editor.lines(), ["acceptedbase"]);
|
||||
assert_eq!(session.editor.lines(), ["base"]);
|
||||
assert!(
|
||||
session
|
||||
.editor
|
||||
.documents()
|
||||
.any(|document| document.lines == ["acceptedbase"])
|
||||
);
|
||||
session
|
||||
.requests
|
||||
.get_mut("fixture")
|
||||
.unwrap()
|
||||
.pending
|
||||
.insert(4, current);
|
||||
.insert(4, readonly);
|
||||
assert!(
|
||||
session
|
||||
.apply_response("fixture", 4, vec![Effect::Message("allowed".into())])
|
||||
|
||||
+601
-128
@@ -42,6 +42,8 @@ pub struct SyntaxCache {
|
||||
next_source: String,
|
||||
line_starts: Vec<usize>,
|
||||
lines: Vec<Vec<SyntaxSpan>>,
|
||||
highlighted: Vec<bool>,
|
||||
context: Option<LayerContext>,
|
||||
parser: Parser,
|
||||
tree: Option<Tree>,
|
||||
injection_parser: Parser,
|
||||
@@ -65,7 +67,10 @@ impl SyntaxCache {
|
||||
}
|
||||
|
||||
pub(crate) fn update_text(&mut self, text: &str, path: &Path) {
|
||||
if self.source == text && self.path.as_deref() == Some(path) {
|
||||
if self.source == text
|
||||
&& self.path.as_deref() == Some(path)
|
||||
&& self.highlighted.iter().all(|highlighted| *highlighted)
|
||||
{
|
||||
return;
|
||||
}
|
||||
let lines: Vec<String> = text.split('\n').map(str::to_owned).collect();
|
||||
@@ -78,13 +83,41 @@ impl SyntaxCache {
|
||||
|
||||
/// `revision` must change whenever line content changes; path changes are detected separately.
|
||||
pub fn update(&mut self, lines: &[String], path: Option<&Path>, revision: u64) {
|
||||
self.update_rows(
|
||||
lines,
|
||||
path,
|
||||
revision,
|
||||
std::slice::from_ref(&(0..lines.len())),
|
||||
);
|
||||
}
|
||||
|
||||
/// Parse the original full source, but collect colors only for these end-exclusive source rows.
|
||||
/// Unrequested rows may be empty; requesting more rows at the same revision reuses parsed trees.
|
||||
pub(crate) fn update_visible(
|
||||
&mut self,
|
||||
lines: &[String],
|
||||
path: Option<&Path>,
|
||||
revision: u64,
|
||||
visible: &[std::ops::Range<usize>],
|
||||
) {
|
||||
self.update_rows(lines, path, revision, visible);
|
||||
}
|
||||
|
||||
fn update_rows(
|
||||
&mut self,
|
||||
lines: &[String],
|
||||
path: Option<&Path>,
|
||||
revision: u64,
|
||||
visible: &[std::ops::Range<usize>],
|
||||
) {
|
||||
let path_changed = self.path.as_deref() != path;
|
||||
if self.revision == Some(revision) && !path_changed {
|
||||
return;
|
||||
}
|
||||
let syntax = languages::detect(path, lines.first().map(String::as_str));
|
||||
let language_changed = !same_language(self.syntax, syntax);
|
||||
let revision_changed = self.revision != Some(revision);
|
||||
let syntax = if path_changed || revision_changed {
|
||||
languages::detect(path, lines.first().map(String::as_str))
|
||||
} else {
|
||||
self.syntax
|
||||
};
|
||||
let language_changed = !same_language(self.syntax, syntax);
|
||||
self.revision = Some(revision);
|
||||
if path_changed {
|
||||
self.path = path.map(Path::to_path_buf);
|
||||
@@ -114,30 +147,76 @@ impl SyntaxCache {
|
||||
start += line.len() + 1;
|
||||
}
|
||||
self.lines.resize_with(lines.len(), Vec::new);
|
||||
self.highlighted.resize(lines.len(), false);
|
||||
}
|
||||
if edit.is_none() && !language_changed && self.error.is_none() {
|
||||
return;
|
||||
if edit.is_some() || language_changed {
|
||||
for spans in &mut self.lines {
|
||||
spans.clear();
|
||||
}
|
||||
self.highlighted.fill(false);
|
||||
self.context = None;
|
||||
}
|
||||
for spans in &mut self.lines {
|
||||
spans.clear();
|
||||
}
|
||||
self.error = None;
|
||||
if language_changed {
|
||||
self.tree = None;
|
||||
self.injections.clear();
|
||||
}
|
||||
let mut requested = Vec::new();
|
||||
for rows in visible {
|
||||
let end = rows.end.min(lines.len());
|
||||
let mut row = rows.start.min(end);
|
||||
while row < end {
|
||||
if self.highlighted[row] {
|
||||
row += 1;
|
||||
continue;
|
||||
}
|
||||
let start = row;
|
||||
while row < end && !self.highlighted[row] {
|
||||
row += 1;
|
||||
}
|
||||
requested.push(Range {
|
||||
start_byte: self.line_starts[start],
|
||||
end_byte: self
|
||||
.line_starts
|
||||
.get(row)
|
||||
.copied()
|
||||
.unwrap_or(self.source.len()),
|
||||
start_point: Point::new(start, 0),
|
||||
end_point: if row < lines.len() {
|
||||
Point::new(row, 0)
|
||||
} else {
|
||||
Point::new(
|
||||
lines.len().saturating_sub(1),
|
||||
lines.last().map_or(0, String::len),
|
||||
)
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
normalize_ranges(&mut requested);
|
||||
if requested.is_empty() && edit.is_none() && !language_changed {
|
||||
return;
|
||||
}
|
||||
self.error = None;
|
||||
let Some(syntax) = syntax else {
|
||||
self.tree = None;
|
||||
self.injections.clear();
|
||||
self.highlighted.fill(true);
|
||||
return;
|
||||
};
|
||||
if let Err(error) = self.highlight(syntax, edit.as_ref(), language_changed) {
|
||||
if let Err(error) = self.highlight(syntax, edit.as_ref(), language_changed, &requested) {
|
||||
self.error = Some(format!("{} syntax: {error}", syntax.name));
|
||||
self.tree = None;
|
||||
self.injections.clear();
|
||||
self.context = None;
|
||||
self.highlighted.fill(false);
|
||||
for spans in &mut self.lines {
|
||||
spans.clear();
|
||||
}
|
||||
} else {
|
||||
for rows in visible {
|
||||
let end = rows.end.min(lines.len());
|
||||
self.highlighted[rows.start.min(end)..end].fill(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,6 +245,7 @@ impl SyntaxCache {
|
||||
syntax: &'static LanguageSpec,
|
||||
edit: Option<&InputEdit>,
|
||||
language_changed: bool,
|
||||
visible: &[Range],
|
||||
) -> Result<(), String> {
|
||||
let primary_configuration = configuration(syntax)?;
|
||||
if language_changed || self.tree.is_none() {
|
||||
@@ -194,15 +274,19 @@ impl SyntaxCache {
|
||||
start_byte: 0,
|
||||
end_byte: self.source.len(),
|
||||
start_point: Point::new(0, 0),
|
||||
end_point: point_at(&self.source, self.source.len()),
|
||||
end_point: Point::new(
|
||||
self.line_starts.len().saturating_sub(1),
|
||||
self.source.len() - self.line_starts.last().copied().unwrap_or(0),
|
||||
),
|
||||
};
|
||||
self.highlights.clear();
|
||||
// ponytail: query the whole incremental tree; restrict changed ranges if query time dominates.
|
||||
let mut queue: VecDeque<_> = collect_layer(
|
||||
primary_configuration,
|
||||
self.tree.as_ref().unwrap(),
|
||||
&self.source,
|
||||
&[full_range],
|
||||
visible,
|
||||
&mut self.context,
|
||||
0,
|
||||
None,
|
||||
&mut self.cursor,
|
||||
@@ -220,6 +304,7 @@ impl SyntaxCache {
|
||||
if let Some(edit) = edit {
|
||||
layer.tree.edit(edit);
|
||||
layer.ranges = layer.tree.included_ranges();
|
||||
layer.context = None;
|
||||
}
|
||||
}
|
||||
while let Some(request) = queue.pop_front() {
|
||||
@@ -250,8 +335,9 @@ impl SyntaxCache {
|
||||
let unchanged = old
|
||||
.as_ref()
|
||||
.is_some_and(|layer| !layer.dirty && layer.ranges == request.ranges);
|
||||
let tree = if unchanged {
|
||||
old.unwrap().tree
|
||||
let (tree, mut context) = if unchanged {
|
||||
let old = old.unwrap();
|
||||
(old.tree, old.context)
|
||||
} else {
|
||||
self.injection_parser
|
||||
.set_language(&request.syntax.language)
|
||||
@@ -263,7 +349,8 @@ impl SyntaxCache {
|
||||
{
|
||||
self.injection_parses += 1;
|
||||
}
|
||||
self.injection_parser
|
||||
let tree = self
|
||||
.injection_parser
|
||||
.parse(
|
||||
self.source.as_bytes(),
|
||||
old.as_ref().map(|layer| &layer.tree),
|
||||
@@ -273,14 +360,19 @@ impl SyntaxCache {
|
||||
"{} injection parser stopped without a tree",
|
||||
request.syntax.name
|
||||
)
|
||||
})?
|
||||
})?;
|
||||
(tree, None)
|
||||
};
|
||||
// Markdown's inline grammar refines the block grammar, so plain inline text keeps
|
||||
// its enclosing heading/link foreground. Other languages replace host strings/markup.
|
||||
let refines_host = languages::by_name("markdown_inline")
|
||||
.is_some_and(|inline| std::ptr::eq(inline, request.syntax));
|
||||
if !refines_host {
|
||||
for range in &request.ranges {
|
||||
for range in request
|
||||
.ranges
|
||||
.iter()
|
||||
.filter(|range| intersects(visible, range.start_byte, range.end_byte))
|
||||
{
|
||||
self.highlights.push(Highlight {
|
||||
start: range.start_byte,
|
||||
end: range.end_byte,
|
||||
@@ -296,6 +388,8 @@ impl SyntaxCache {
|
||||
&tree,
|
||||
&self.source,
|
||||
&request.ranges,
|
||||
visible,
|
||||
&mut context,
|
||||
request.depth,
|
||||
Some(request.parent),
|
||||
&mut self.cursor,
|
||||
@@ -314,6 +408,7 @@ impl SyntaxCache {
|
||||
ranges: request.ranges,
|
||||
depth: request.depth,
|
||||
dirty: false,
|
||||
context,
|
||||
});
|
||||
}
|
||||
render_spans(
|
||||
@@ -322,6 +417,7 @@ impl SyntaxCache {
|
||||
&mut self.lines,
|
||||
&self.highlights,
|
||||
&mut self.events,
|
||||
visible,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -380,10 +476,39 @@ fn point_at(source: &str, byte: usize) -> Point {
|
||||
struct Configuration {
|
||||
syntax: &'static LanguageSpec,
|
||||
query: Query,
|
||||
context_query: Query,
|
||||
highlights_query: Query,
|
||||
locals_start: usize,
|
||||
highlights_start: usize,
|
||||
}
|
||||
|
||||
impl Configuration {
|
||||
fn new(
|
||||
syntax: &'static LanguageSpec,
|
||||
query: Query,
|
||||
locals_start: usize,
|
||||
highlights_start: usize,
|
||||
) -> Self {
|
||||
let mut context_query = query.deep_clone();
|
||||
let mut highlights_query = query.deep_clone();
|
||||
for pattern in 0..query.pattern_count() {
|
||||
if pattern < highlights_start {
|
||||
highlights_query.disable_pattern(pattern);
|
||||
} else {
|
||||
context_query.disable_pattern(pattern);
|
||||
}
|
||||
}
|
||||
Self {
|
||||
syntax,
|
||||
query,
|
||||
context_query,
|
||||
highlights_query,
|
||||
locals_start,
|
||||
highlights_start,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn configuration(syntax: &'static LanguageSpec) -> Result<&'static Configuration, String> {
|
||||
static CONFIGURATIONS: LazyLock<Vec<(&'static LanguageSpec, Result<Configuration, String>)>> =
|
||||
LazyLock::new(|| {
|
||||
@@ -429,12 +554,12 @@ fn compile_configuration(syntax: &'static LanguageSpec) -> Result<Configuration,
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(Configuration {
|
||||
Ok(Configuration::new(
|
||||
syntax,
|
||||
query,
|
||||
locals_start,
|
||||
highlights_start,
|
||||
})
|
||||
))
|
||||
}
|
||||
|
||||
fn classify(capture: &str, numeric_literal: bool) -> Option<SyntaxKind> {
|
||||
@@ -463,8 +588,10 @@ struct Layer {
|
||||
ranges: Vec<Range>,
|
||||
depth: usize,
|
||||
dirty: bool,
|
||||
context: Option<LayerContext>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Injection {
|
||||
syntax: &'static LanguageSpec,
|
||||
parent: &'static LanguageSpec,
|
||||
@@ -473,7 +600,12 @@ struct Injection {
|
||||
ancestors: Vec<(&'static LanguageSpec, Vec<Range>)>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct LayerContext {
|
||||
captures: Vec<Capture>,
|
||||
injections: Vec<Injection>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Eq, PartialEq)]
|
||||
struct Capture {
|
||||
node: usize,
|
||||
start: usize,
|
||||
@@ -484,6 +616,34 @@ struct Capture {
|
||||
numeric_literal: bool,
|
||||
}
|
||||
|
||||
impl Capture {
|
||||
fn new(
|
||||
capture: &tree_sitter::QueryCapture<'_>,
|
||||
pattern: usize,
|
||||
value_end: usize,
|
||||
name: &str,
|
||||
) -> Self {
|
||||
Self {
|
||||
node: capture.node.id(),
|
||||
start: capture.node.start_byte(),
|
||||
end: capture.node.end_byte(),
|
||||
capture: capture.index,
|
||||
pattern,
|
||||
value_end,
|
||||
numeric_literal: name.starts_with("constant")
|
||||
&& matches!(
|
||||
capture.node.kind(),
|
||||
"integer_literal"
|
||||
| "float_literal"
|
||||
| "number_literal"
|
||||
| "integer"
|
||||
| "float"
|
||||
| "number"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Highlight {
|
||||
start: usize,
|
||||
end: usize,
|
||||
@@ -510,6 +670,8 @@ fn collect_layer(
|
||||
tree: &Tree,
|
||||
source: &str,
|
||||
ranges: &[Range],
|
||||
visible: &[Range],
|
||||
context: &mut Option<LayerContext>,
|
||||
depth: usize,
|
||||
parent: Option<&'static LanguageSpec>,
|
||||
cursor: &mut QueryCursor,
|
||||
@@ -519,102 +681,128 @@ fn collect_layer(
|
||||
let query = &configuration.query;
|
||||
let names = query.capture_names();
|
||||
captures.clear();
|
||||
let mut injections = Vec::new();
|
||||
let mut combined: HashMap<(usize, usize), usize> = HashMap::new();
|
||||
let mut matches = cursor.matches(query, tree.root_node(), source.as_bytes());
|
||||
while let Some(found) = matches.next() {
|
||||
let pattern = found.pattern_index;
|
||||
if pattern < configuration.locals_start {
|
||||
let mut language = None;
|
||||
let mut nodes = Vec::new();
|
||||
for capture in found.captures() {
|
||||
match names[capture.index as usize] {
|
||||
"injection.language" => {
|
||||
language = source[capture.node.byte_range()]
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.and_then(languages::by_name);
|
||||
}
|
||||
"injection.content" => nodes.push(capture.node),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut include_children = false;
|
||||
let mut is_combined = false;
|
||||
for property in query.property_settings(pattern) {
|
||||
match property.key.as_ref() {
|
||||
"injection.language" => {
|
||||
language = property.value.as_deref().and_then(languages::by_name);
|
||||
}
|
||||
"injection.self" => language = Some(configuration.syntax),
|
||||
"injection.parent" => language = parent,
|
||||
"injection.include-children" => include_children = true,
|
||||
"injection.combined" => is_combined = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(language) = language {
|
||||
let mut content = injection_ranges(&nodes, ranges, include_children);
|
||||
if !content.is_empty() {
|
||||
let key = (pattern, language as *const LanguageSpec as usize);
|
||||
if is_combined && let Some(index) = combined.get(&key) {
|
||||
let injection: &mut Injection = &mut injections[*index];
|
||||
injection.ranges.append(&mut content);
|
||||
} else {
|
||||
if is_combined {
|
||||
combined.insert(key, injections.len());
|
||||
let mut injections;
|
||||
if let Some(context) = context {
|
||||
captures.extend_from_slice(&context.captures);
|
||||
injections = context.injections.clone();
|
||||
} else {
|
||||
injections = Vec::new();
|
||||
let mut combined: HashMap<(usize, usize), usize> = HashMap::new();
|
||||
cursor.set_byte_range(0..source.len());
|
||||
let mut matches = cursor.matches(
|
||||
&configuration.context_query,
|
||||
tree.root_node(),
|
||||
source.as_bytes(),
|
||||
);
|
||||
while let Some(found) = matches.next() {
|
||||
let pattern = found.pattern_index;
|
||||
if pattern < configuration.locals_start {
|
||||
let mut language = None;
|
||||
let mut nodes = Vec::new();
|
||||
for capture in found.captures() {
|
||||
match names[capture.index as usize] {
|
||||
"injection.language" => {
|
||||
language = source[capture.node.byte_range()]
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.and_then(languages::by_name);
|
||||
}
|
||||
"injection.content" => nodes.push(capture.node),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut include_children = false;
|
||||
let mut is_combined = false;
|
||||
for property in query.property_settings(pattern) {
|
||||
match property.key.as_ref() {
|
||||
"injection.language" => {
|
||||
language = property.value.as_deref().and_then(languages::by_name);
|
||||
}
|
||||
"injection.self" => language = Some(configuration.syntax),
|
||||
"injection.parent" => language = parent,
|
||||
"injection.include-children" => include_children = true,
|
||||
"injection.combined" => is_combined = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(language) = language {
|
||||
let mut content = injection_ranges(&nodes, ranges, include_children);
|
||||
if !content.is_empty() {
|
||||
let key = (pattern, language as *const LanguageSpec as usize);
|
||||
if is_combined && let Some(index) = combined.get(&key) {
|
||||
let injection: &mut Injection = &mut injections[*index];
|
||||
injection.ranges.append(&mut content);
|
||||
} else {
|
||||
if is_combined {
|
||||
combined.insert(key, injections.len());
|
||||
}
|
||||
injections.push(Injection {
|
||||
syntax: language,
|
||||
parent: configuration.syntax,
|
||||
ranges: content,
|
||||
depth: depth + 1,
|
||||
ancestors: vec![(configuration.syntax, ranges.to_vec())],
|
||||
});
|
||||
}
|
||||
injections.push(Injection {
|
||||
syntax: language,
|
||||
parent: configuration.syntax,
|
||||
ranges: content,
|
||||
depth: depth + 1,
|
||||
ancestors: vec![(configuration.syntax, ranges.to_vec())],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let value_end = found
|
||||
.captures()
|
||||
.iter()
|
||||
.find(|capture| names[capture.index as usize] == "local.definition-value")
|
||||
.map_or(0, |capture| capture.node.end_byte());
|
||||
for capture in found.captures() {
|
||||
let name = names[capture.index as usize];
|
||||
if name.starts_with('_') || name == "local.definition-value" {
|
||||
continue;
|
||||
}
|
||||
captures.push(Capture {
|
||||
node: capture.node.id(),
|
||||
start: capture.node.start_byte(),
|
||||
end: capture.node.end_byte(),
|
||||
capture: capture.index,
|
||||
pattern,
|
||||
value_end,
|
||||
numeric_literal: name.starts_with("constant")
|
||||
&& matches!(
|
||||
capture.node.kind(),
|
||||
"integer_literal"
|
||||
| "float_literal"
|
||||
| "number_literal"
|
||||
| "integer"
|
||||
| "float"
|
||||
| "number"
|
||||
),
|
||||
});
|
||||
let value_end = found
|
||||
.captures()
|
||||
.iter()
|
||||
.find(|capture| names[capture.index as usize] == "local.definition-value")
|
||||
.map_or(0, |capture| capture.node.end_byte());
|
||||
for capture in found.captures() {
|
||||
let name = names[capture.index as usize];
|
||||
if name.starts_with('_') || name == "local.definition-value" {
|
||||
continue;
|
||||
}
|
||||
captures.push(Capture::new(capture, pattern, value_end, name));
|
||||
}
|
||||
}
|
||||
if cursor.did_exceed_match_limit() {
|
||||
return Err(format!(
|
||||
"{} query exceeded its match limit",
|
||||
configuration.syntax.name
|
||||
));
|
||||
}
|
||||
for injection in &mut injections {
|
||||
normalize_ranges(&mut injection.ranges);
|
||||
}
|
||||
// Locals must be resolved in source order, including definitions above the viewport.
|
||||
// Query only their nodes for the foreground that references inherit.
|
||||
let definitions: BTreeSet<_> = captures
|
||||
.iter()
|
||||
.filter(|capture| names[capture.capture as usize] == "local.definition")
|
||||
.map(|capture| capture.node)
|
||||
.collect();
|
||||
let mut definition_ranges: Vec<_> = captures
|
||||
.iter()
|
||||
.filter(|capture| definitions.contains(&capture.node))
|
||||
.map(|capture| Range {
|
||||
start_byte: capture.start,
|
||||
end_byte: capture.end,
|
||||
start_point: Point::new(0, 0),
|
||||
end_point: Point::new(0, 0),
|
||||
})
|
||||
.collect();
|
||||
normalize_ranges(&mut definition_ranges);
|
||||
collect_highlight_captures(
|
||||
configuration,
|
||||
tree,
|
||||
source,
|
||||
&definition_ranges,
|
||||
Some(&definitions),
|
||||
cursor,
|
||||
captures,
|
||||
)?;
|
||||
*context = Some(LayerContext {
|
||||
captures: captures.clone(),
|
||||
injections: injections.clone(),
|
||||
});
|
||||
}
|
||||
if cursor.did_exceed_match_limit() {
|
||||
return Err(format!(
|
||||
"{} query exceeded its match limit",
|
||||
configuration.syntax.name
|
||||
));
|
||||
}
|
||||
for injection in &mut injections {
|
||||
normalize_ranges(&mut injection.ranges);
|
||||
}
|
||||
collect_highlight_captures(configuration, tree, source, visible, None, cursor, captures)?;
|
||||
captures.sort_unstable_by_key(|capture| {
|
||||
(
|
||||
capture.start,
|
||||
@@ -624,6 +812,7 @@ fn collect_layer(
|
||||
capture.capture,
|
||||
)
|
||||
});
|
||||
captures.dedup();
|
||||
let mut scopes = vec![Scope {
|
||||
end: usize::MAX,
|
||||
inherits: false,
|
||||
@@ -737,7 +926,7 @@ fn collect_layer(
|
||||
{
|
||||
let low = first.start.max(range.start_byte);
|
||||
let high = first.end.min(range.end_byte);
|
||||
if low < high {
|
||||
if low < high && intersects(visible, low, high) {
|
||||
highlights.push(Highlight {
|
||||
start: low,
|
||||
end: high,
|
||||
@@ -754,6 +943,57 @@ fn collect_layer(
|
||||
Ok(injections)
|
||||
}
|
||||
|
||||
fn intersects(ranges: &[Range], start: usize, end: usize) -> bool {
|
||||
ranges
|
||||
.get(ranges.partition_point(|range| range.end_byte <= start))
|
||||
.is_some_and(|range| range.start_byte < end)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn collect_highlight_captures(
|
||||
configuration: &Configuration,
|
||||
tree: &Tree,
|
||||
source: &str,
|
||||
ranges: &[Range],
|
||||
definitions: Option<&BTreeSet<usize>>,
|
||||
cursor: &mut QueryCursor,
|
||||
captures: &mut Vec<Capture>,
|
||||
) -> Result<(), String> {
|
||||
let names = configuration.query.capture_names();
|
||||
for range in ranges {
|
||||
if range.start_byte == range.end_byte {
|
||||
continue;
|
||||
}
|
||||
cursor.set_byte_range(range.start_byte..range.end_byte);
|
||||
let mut matches = cursor.matches(
|
||||
&configuration.highlights_query,
|
||||
tree.root_node(),
|
||||
source.as_bytes(),
|
||||
);
|
||||
while let Some(found) = matches.next() {
|
||||
for capture in found.captures() {
|
||||
let name = names[capture.index as usize];
|
||||
if name.starts_with('_')
|
||||
|| capture.node.end_byte() <= range.start_byte
|
||||
|| capture.node.start_byte() >= range.end_byte
|
||||
|| definitions
|
||||
.is_some_and(|definitions| !definitions.contains(&capture.node.id()))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
captures.push(Capture::new(capture, found.pattern_index, 0, name));
|
||||
}
|
||||
}
|
||||
if cursor.did_exceed_match_limit() {
|
||||
return Err(format!(
|
||||
"{} query exceeded its match limit",
|
||||
configuration.syntax.name
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn capture_priority(query: &Query, capture: &Capture) -> i32 {
|
||||
query
|
||||
.property_settings(capture.pattern)
|
||||
@@ -851,6 +1091,7 @@ fn render_spans(
|
||||
lines: &mut [Vec<SyntaxSpan>],
|
||||
highlights: &[Highlight],
|
||||
events: &mut Vec<(usize, bool, usize)>,
|
||||
visible: &[Range],
|
||||
) {
|
||||
events.clear();
|
||||
for (index, highlight) in highlights.iter().enumerate() {
|
||||
@@ -875,7 +1116,20 @@ fn render_spans(
|
||||
&& let Some((_, current)) = active.last()
|
||||
&& let Some(kind) = highlights[*current].kind
|
||||
{
|
||||
append_global(source, starts, lines, previous, byte, kind);
|
||||
let start = visible.partition_point(|range| range.end_byte <= previous);
|
||||
for range in visible[start..]
|
||||
.iter()
|
||||
.take_while(|range| range.start_byte < byte)
|
||||
{
|
||||
append_global(
|
||||
source,
|
||||
starts,
|
||||
lines,
|
||||
previous.max(range.start_byte),
|
||||
byte.min(range.end_byte),
|
||||
kind,
|
||||
);
|
||||
}
|
||||
}
|
||||
if entering {
|
||||
active.insert((rank(index), index));
|
||||
@@ -933,6 +1187,179 @@ mod tests {
|
||||
source.split('\n').map(str::to_owned).collect()
|
||||
}
|
||||
|
||||
fn source_range(source: &str) -> Range {
|
||||
Range {
|
||||
start_byte: 0,
|
||||
end_byte: source.len(),
|
||||
start_point: Point::new(0, 0),
|
||||
end_point: point_at(source, source.len()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visible_windows_match_full_colors_after_edits_and_scrolling_without_reparsing() {
|
||||
let cases = [
|
||||
(
|
||||
"a.rs",
|
||||
"fn main() {\n/* start\ninside é 👩💻\nend */\nlet n = 42;\nlet text = \"unfinished",
|
||||
),
|
||||
("a.py", "text = '''first\ninside é\nlast'''\nanswer = 42"),
|
||||
(
|
||||
"a.js",
|
||||
"const action = () => 42;\nfunction hello(require) {\n require('local');\n action;\n}\nrequire('global');\naction;",
|
||||
),
|
||||
(
|
||||
"a.md",
|
||||
"# Heading **bold**\n\n```rust\nfn main() {\n let n = 42;\n}\n```\n\n```python\ndef hello():\n return 7\n```\n\n```unknown\nfn plain() {}\n```",
|
||||
),
|
||||
(
|
||||
"a.md",
|
||||
"> # Heading **bold**\n>\n> ```rust\n> fn main() {\n> let n = 42;\n> }\n> ```",
|
||||
),
|
||||
(
|
||||
"a.md",
|
||||
"```html\n<script>\nfunction nested() {\n return 42;\n}\n</script>\n```",
|
||||
),
|
||||
(
|
||||
"a.html",
|
||||
"<div>é</div>\n<script>\nconst action = () => 42;\naction;\n</script>\n<style>\nbody { width: 7px; }\n</style>",
|
||||
),
|
||||
(
|
||||
"a.js",
|
||||
"const page = html`<div>${value}</div>\n<script>\nfunction inside() { return 42; }\n</script>`;",
|
||||
),
|
||||
];
|
||||
for (path, original) in cases {
|
||||
let mut visible = SyntaxCache::new();
|
||||
for (revision, source) in [
|
||||
original.to_owned(),
|
||||
original.replace("42", "123"),
|
||||
format!("\n{original}"),
|
||||
original.replace("() => 42", "42"),
|
||||
]
|
||||
.iter()
|
||||
.enumerate()
|
||||
{
|
||||
let input = lines(source);
|
||||
let middle = input.len() / 2;
|
||||
visible.update_visible(
|
||||
&input,
|
||||
Some(Path::new(path)),
|
||||
revision as u64,
|
||||
std::slice::from_ref(&(middle..middle + 1)),
|
||||
);
|
||||
let full = cache(source, path);
|
||||
assert_eq!(visible.error(), None, "{path}");
|
||||
assert_eq!(
|
||||
visible.line(middle),
|
||||
full.line(middle),
|
||||
"{path} revision {revision}"
|
||||
);
|
||||
assert!(
|
||||
visible
|
||||
.lines
|
||||
.iter()
|
||||
.enumerate()
|
||||
.all(|(row, spans)| row == middle || spans.is_empty())
|
||||
);
|
||||
let parses = visible.parse_counts();
|
||||
let context = visible.context.as_ref().unwrap().captures.as_ptr();
|
||||
// Unsorted, overlapping and out-of-bounds windows use original source rows.
|
||||
for row in (0..input.len()).rev() {
|
||||
visible.update_visible(
|
||||
&input,
|
||||
Some(Path::new(path)),
|
||||
revision as u64,
|
||||
&[
|
||||
row..row + 1,
|
||||
middle..middle + 1,
|
||||
row..row + 1,
|
||||
input.len()..usize::MAX,
|
||||
],
|
||||
);
|
||||
assert_eq!(
|
||||
visible.line(row),
|
||||
full.line(row),
|
||||
"{path} revision {revision} row {row}"
|
||||
);
|
||||
assert_eq!(
|
||||
visible.parse_counts(),
|
||||
parses,
|
||||
"{path}: scrolling reparsed a tree"
|
||||
);
|
||||
assert_eq!(visible.context.as_ref().unwrap().captures.as_ptr(), context);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visible_highlight_collection_stays_bounded_by_the_requested_rows() {
|
||||
let mut input: Vec<_> = (0..1000)
|
||||
.map(|row| format!("fn function_{row}() {{ let value = 42; }}"))
|
||||
.collect();
|
||||
let path = Path::new("large.rs");
|
||||
let mut visible = SyntaxCache::new();
|
||||
visible.update_visible(&input, Some(path), 0, std::slice::from_ref(&(500..502)));
|
||||
assert_eq!(visible.parses, 1);
|
||||
assert!(visible.captures.len() < 50);
|
||||
assert!(visible.highlights.len() < 30);
|
||||
assert!(visible.lines[..500].iter().all(Vec::is_empty));
|
||||
assert!(visible.lines[502..].iter().all(Vec::is_empty));
|
||||
input[500] = "fn changed() { let value = \"é 👩💻\"; }".to_owned();
|
||||
visible.update_visible(&input, Some(path), 1, std::slice::from_ref(&(500..502)));
|
||||
assert_eq!(visible.incremental_parses, 1);
|
||||
assert!(visible.captures.len() < 50);
|
||||
let mut full = SyntaxCache::new();
|
||||
full.update(&input, Some(path), 1);
|
||||
assert!(full.captures.len() > 1000);
|
||||
for row in 500..502 {
|
||||
assert_eq!(visible.line(row), full.line(row));
|
||||
}
|
||||
let parses = visible.parse_counts();
|
||||
visible.update_visible(&input, Some(path), 1, std::slice::from_ref(&(950..952)));
|
||||
assert_eq!(visible.parse_counts(), parses);
|
||||
assert!(visible.captures.len() < 50);
|
||||
for row in 950..952 {
|
||||
assert_eq!(visible.line(row), full.line(row));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visible_cache_supports_full_updates_and_same_revision_path_changes() {
|
||||
let input = lines("const element = <div title=\"hi\" />;\nconst answer = 42;");
|
||||
let mut visible = SyntaxCache::new();
|
||||
visible.update_visible(
|
||||
&input,
|
||||
Some(Path::new("a.ts")),
|
||||
0,
|
||||
std::slice::from_ref(&(1..2)),
|
||||
);
|
||||
let parses = visible.parse_counts();
|
||||
visible.update(&input, Some(Path::new("a.ts")), 0);
|
||||
assert_eq!(visible.parse_counts(), parses);
|
||||
assert_fresh(&visible, &input.join("\n"), "a.ts");
|
||||
visible.update_visible(
|
||||
&input,
|
||||
Some(Path::new("a.tsx")),
|
||||
0,
|
||||
std::slice::from_ref(&(0..1)),
|
||||
);
|
||||
assert_eq!(visible.parses, parses.0 + 1);
|
||||
assert_eq!(visible.line(0), cache(&input.join("\n"), "a.tsx").line(0));
|
||||
assert!(visible.line(1).is_empty());
|
||||
visible.update_text(&input.join("\n"), Path::new("a.tsx"));
|
||||
assert_fresh(&visible, &input.join("\n"), "a.tsx");
|
||||
visible.update_visible(
|
||||
&input,
|
||||
Some(Path::new("a.txt")),
|
||||
0,
|
||||
std::slice::from_ref(&(0..2)),
|
||||
);
|
||||
assert_eq!(visible.language(), None);
|
||||
assert!(visible.lines.iter().all(Vec::is_empty));
|
||||
}
|
||||
|
||||
fn cache(source: &str, path: &str) -> SyntaxCache {
|
||||
let mut cache = SyntaxCache::new();
|
||||
cache.update(&lines(source), Some(Path::new(path)), 0);
|
||||
@@ -1433,7 +1860,14 @@ mod tests {
|
||||
pattern: 0,
|
||||
},
|
||||
];
|
||||
render_spans(source, &[0], &mut spans, &highlights, &mut events);
|
||||
render_spans(
|
||||
source,
|
||||
&[0],
|
||||
&mut spans,
|
||||
&highlights,
|
||||
&mut events,
|
||||
&[source_range(source)],
|
||||
);
|
||||
assert_eq!(
|
||||
spans[0],
|
||||
vec![
|
||||
@@ -1472,12 +1906,7 @@ mod tests {
|
||||
let highlights_start = (0..query.pattern_count())
|
||||
.filter(|pattern| query.start_byte_for_pattern(*pattern) < locals.len() + 1)
|
||||
.count();
|
||||
let configuration = Configuration {
|
||||
syntax,
|
||||
query,
|
||||
locals_start: 0,
|
||||
highlights_start,
|
||||
};
|
||||
let configuration = Configuration::new(syntax, query, 0, highlights_start);
|
||||
let source = "const value = value;\nvalue;\nfunction inner() { value; }";
|
||||
let cache = self::cache(source, "a.js");
|
||||
let mut highlights = Vec::new();
|
||||
@@ -1485,12 +1914,9 @@ mod tests {
|
||||
&configuration,
|
||||
cache.tree.as_ref().unwrap(),
|
||||
source,
|
||||
&[Range {
|
||||
start_byte: 0,
|
||||
end_byte: source.len(),
|
||||
start_point: Point::new(0, 0),
|
||||
end_point: point_at(source, source.len()),
|
||||
}],
|
||||
&[source_range(source)],
|
||||
&[source_range(source)],
|
||||
&mut None,
|
||||
0,
|
||||
None,
|
||||
&mut QueryCursor::new(),
|
||||
@@ -1504,7 +1930,14 @@ mod tests {
|
||||
source.find("\nvalue;").unwrap() + 1,
|
||||
source.find("\nfunction").unwrap() + 1,
|
||||
];
|
||||
render_spans(source, &starts, &mut spans, &highlights, &mut Vec::new());
|
||||
render_spans(
|
||||
source,
|
||||
&starts,
|
||||
&mut spans,
|
||||
&highlights,
|
||||
&mut Vec::new(),
|
||||
&[source_range(source)],
|
||||
);
|
||||
let kind = |row: usize, byte: usize| {
|
||||
spans[row].iter().find_map(|span| {
|
||||
(span.start_byte <= byte && byte < span.end_byte).then_some(span.kind)
|
||||
@@ -1514,6 +1947,46 @@ mod tests {
|
||||
assert_eq!(kind(0, 14), Some(SyntaxKind::Function));
|
||||
assert_eq!(kind(1, 0), Some(SyntaxKind::Type));
|
||||
assert_eq!(kind(2, 19), Some(SyntaxKind::Function));
|
||||
let mut context = None;
|
||||
for row in [1, 2] {
|
||||
let window = [Range {
|
||||
start_byte: starts[row],
|
||||
end_byte: starts.get(row + 1).copied().unwrap_or(source.len()),
|
||||
start_point: Point::new(row, 0),
|
||||
end_point: point_at(source, starts.get(row + 1).copied().unwrap_or(source.len())),
|
||||
}];
|
||||
let mut visible_highlights = Vec::new();
|
||||
collect_layer(
|
||||
&configuration,
|
||||
cache.tree.as_ref().unwrap(),
|
||||
source,
|
||||
&[source_range(source)],
|
||||
&window,
|
||||
&mut context,
|
||||
0,
|
||||
None,
|
||||
&mut QueryCursor::new(),
|
||||
&mut Vec::new(),
|
||||
&mut visible_highlights,
|
||||
)
|
||||
.unwrap();
|
||||
let mut visible_spans = vec![Vec::new(); 3];
|
||||
render_spans(
|
||||
source,
|
||||
&starts,
|
||||
&mut visible_spans,
|
||||
&visible_highlights,
|
||||
&mut Vec::new(),
|
||||
&window,
|
||||
);
|
||||
assert_eq!(visible_spans[row], spans[row]);
|
||||
assert!(
|
||||
visible_spans
|
||||
.iter()
|
||||
.enumerate()
|
||||
.all(|(other, spans)| other == row || spans.is_empty())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+1940
-172
File diff suppressed because it is too large
Load Diff
+201
@@ -0,0 +1,201 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use crate::tui::TAB_WIDTH;
|
||||
|
||||
/// Terminal-cell coordinates in the original source line, including its tab stops.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) struct Segment {
|
||||
pub start: usize,
|
||||
pub end: usize,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct WrapCache {
|
||||
key: Option<(usize, u64)>,
|
||||
width: usize,
|
||||
rows: BTreeMap<usize, Vec<Segment>>,
|
||||
}
|
||||
|
||||
impl WrapCache {
|
||||
pub(crate) fn prepare(&mut self, key: (usize, u64), width: usize) {
|
||||
let width = width.max(1);
|
||||
if self.key != Some(key) || self.width != width {
|
||||
self.rows.clear();
|
||||
self.key = Some(key);
|
||||
self.width = width;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn segments(&mut self, row: usize, text: &str) -> &[Segment] {
|
||||
let width = self.width.max(1);
|
||||
self.rows.entry(row).or_insert_with(|| {
|
||||
let mut segments = Vec::new();
|
||||
let mut start = 0;
|
||||
let mut column = 0;
|
||||
let mut word_boundary = 0;
|
||||
for grapheme in text.graphemes(true) {
|
||||
let cells = if grapheme == "\t" {
|
||||
TAB_WIDTH - column % TAB_WIDTH
|
||||
} else {
|
||||
UnicodeWidthStr::width(grapheme)
|
||||
};
|
||||
if column + cells - start > width && column > start {
|
||||
let end = if word_boundary > start {
|
||||
word_boundary
|
||||
} else {
|
||||
column
|
||||
};
|
||||
segments.push(Segment { start, end });
|
||||
start = end;
|
||||
if column + cells - start > width && column > start {
|
||||
segments.push(Segment { start, end: column });
|
||||
start = column;
|
||||
}
|
||||
}
|
||||
if grapheme == "\t" {
|
||||
// Tabs are expanded cells, so even a one-column pane preserves them.
|
||||
while column + cells - start > width {
|
||||
let end = start + width;
|
||||
segments.push(Segment { start, end });
|
||||
start = end;
|
||||
}
|
||||
}
|
||||
column += cells;
|
||||
if grapheme.chars().all(char::is_whitespace) {
|
||||
word_boundary = column;
|
||||
}
|
||||
}
|
||||
segments.push(Segment { start, end: column });
|
||||
// A full final segment has no spare cell for the insertion caret.
|
||||
if column - start >= width {
|
||||
segments.push(Segment {
|
||||
start: column,
|
||||
end: column,
|
||||
});
|
||||
}
|
||||
segments
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn caret_segment(segments: &[Segment], column: usize) -> usize {
|
||||
segments
|
||||
.partition_point(|segment| segment.start <= column)
|
||||
.saturating_sub(1)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn layout(text: &str, width: usize) -> Vec<Segment> {
|
||||
let mut cache = WrapCache::default();
|
||||
cache.prepare((1, 0), width);
|
||||
cache.segments(0, text).to_vec()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn words_and_long_words_preserve_every_source_cell() {
|
||||
assert_eq!(
|
||||
layout("one two three", 8),
|
||||
vec![Segment { start: 0, end: 8 }, Segment { start: 8, end: 13 },]
|
||||
);
|
||||
let segments = layout("abcdefgh", 3);
|
||||
assert_eq!(
|
||||
segments,
|
||||
vec![
|
||||
Segment { start: 0, end: 3 },
|
||||
Segment { start: 3, end: 6 },
|
||||
Segment { start: 6, end: 8 },
|
||||
]
|
||||
);
|
||||
assert_eq!(caret_segment(&segments, 3), 1);
|
||||
assert_eq!(caret_segment(&segments, 8), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tabs_use_source_stops_and_unicode_graphemes_remain_intact() {
|
||||
assert_eq!(
|
||||
layout("ab\t界e\u{301} z", 4),
|
||||
vec![
|
||||
Segment { start: 0, end: 4 },
|
||||
Segment { start: 4, end: 8 },
|
||||
Segment { start: 8, end: 9 },
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
layout("界界界", 3),
|
||||
vec![
|
||||
Segment { start: 0, end: 2 },
|
||||
Segment { start: 2, end: 4 },
|
||||
Segment { start: 4, end: 6 },
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
layout(" \t ", 3),
|
||||
vec![Segment { start: 0, end: 3 }, Segment { start: 3, end: 5 },]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn narrow_tabs_preserve_all_expanded_cells_at_original_stops() {
|
||||
assert_eq!(
|
||||
layout("\tX", 2),
|
||||
[
|
||||
Segment { start: 0, end: 2 },
|
||||
Segment { start: 2, end: 4 },
|
||||
Segment { start: 4, end: 5 },
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
layout("a\tX", 2),
|
||||
[
|
||||
Segment { start: 0, end: 1 },
|
||||
Segment { start: 1, end: 3 },
|
||||
Segment { start: 3, end: 5 },
|
||||
Segment { start: 5, end: 5 },
|
||||
]
|
||||
);
|
||||
for source in ["\t", "a\t", "ab\tX", "\t\tX"] {
|
||||
let segments = layout(source, 1);
|
||||
assert!(
|
||||
segments
|
||||
.iter()
|
||||
.all(|segment| segment.end - segment.start <= 1)
|
||||
);
|
||||
assert!(segments.windows(2).all(|pair| pair[0].end == pair[1].start));
|
||||
let end = source.graphemes(true).fold(0, |column, grapheme| {
|
||||
column
|
||||
+ if grapheme == "\t" {
|
||||
TAB_WIDTH - column % TAB_WIDTH
|
||||
} else {
|
||||
1
|
||||
}
|
||||
});
|
||||
assert_eq!(segments.last().unwrap(), &Segment { start: end, end });
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_lines_have_an_insertion_cell_and_cache_invalidation_is_lazy() {
|
||||
let mut cache = WrapCache::default();
|
||||
cache.prepare((7, 2), 4);
|
||||
assert_eq!(
|
||||
cache.segments(90, "abcd"),
|
||||
[Segment { start: 0, end: 4 }, Segment { start: 4, end: 4 },]
|
||||
);
|
||||
assert_eq!(caret_segment(cache.segments(90, "abcd"), 4), 1);
|
||||
assert_eq!(cache.rows.len(), 1);
|
||||
cache.prepare((7, 2), 4);
|
||||
assert_eq!(cache.rows.len(), 1);
|
||||
cache.prepare((7, 2), 8);
|
||||
assert!(cache.rows.is_empty());
|
||||
cache.segments(90, "abcd");
|
||||
cache.prepare((7, 3), 8);
|
||||
assert!(cache.rows.is_empty());
|
||||
assert_eq!(layout("", 0), [Segment { start: 0, end: 0 }]);
|
||||
}
|
||||
}
|
||||
+68
-4
@@ -115,7 +115,7 @@ fn typed_diff_edits_unsaved_working_after_and_preserves_undo_on_explicit_close()
|
||||
session
|
||||
.editor()
|
||||
.diff()
|
||||
.is_some_and(|diff| diff.phase == Phase::View)
|
||||
.is_some_and(|diff| diff.phase == Phase::View && diff.selected_file_loaded())
|
||||
});
|
||||
let comparison = session
|
||||
.editor()
|
||||
@@ -143,8 +143,7 @@ fn typed_diff_edits_unsaved_working_after_and_preserves_undo_on_explicit_close()
|
||||
}
|
||||
session.handle(Input::Escape);
|
||||
assert!(session.editor().diff().is_some());
|
||||
assert_eq!(session.editor().context().text, "uafter\n");
|
||||
assert_eq!(session.editor().cursor(), cursor);
|
||||
assert_eq!(session.editor().context().text, "before\n");
|
||||
assert_eq!(fs::read_to_string(&source).unwrap(), "after\n");
|
||||
|
||||
session.handle(Input::Char('\t'));
|
||||
@@ -152,6 +151,8 @@ fn typed_diff_edits_unsaved_working_after_and_preserves_undo_on_explicit_close()
|
||||
session.editor().diff().unwrap().focus,
|
||||
vamp::diff::Focus::After
|
||||
);
|
||||
assert_eq!(session.editor().context().text, "uafter\n");
|
||||
assert_eq!(session.editor().cursor(), cursor);
|
||||
for input in [Input::Char('i'), Input::Char('x'), Input::Escape] {
|
||||
session.handle(input);
|
||||
}
|
||||
@@ -221,7 +222,7 @@ fn editable_diff_navigation_skips_omitted_lines_without_expanding_excerpts() {
|
||||
session
|
||||
.editor()
|
||||
.diff()
|
||||
.is_some_and(|diff| diff.phase == Phase::View)
|
||||
.is_some_and(|diff| diff.phase == Phase::View && diff.selected_file_loaded())
|
||||
});
|
||||
session.handle(Input::BackTab);
|
||||
let shown = |session: &EditorSession| {
|
||||
@@ -320,3 +321,66 @@ fn editable_diff_navigation_skips_omitted_lines_without_expanding_excerpts() {
|
||||
type_query(&mut session, "]");
|
||||
assert_eq!(shown(&session), [16, 17, 18, 19, 20, 21, 22, 78, 79, 80]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readonly_jump_history_restores_the_comparison_file_and_source_cursor() {
|
||||
let fixture = Fixture::new();
|
||||
fixture.git(&["init", "-q"]);
|
||||
let root = fixture.0.join("repo");
|
||||
for name in ["first", "second"] {
|
||||
let mut lines = (1..=40)
|
||||
.map(|row| format!("// {name}_stable_{row:03}"))
|
||||
.collect::<Vec<_>>();
|
||||
lines[19] = format!("// {name}_before");
|
||||
fs::write(root.join(format!("{name}.rs")), lines.join("\n") + "\n").unwrap();
|
||||
}
|
||||
fixture.git(&["add", "."]);
|
||||
fixture.git(&["commit", "-qm", "base"]);
|
||||
for name in ["first", "second"] {
|
||||
let path = root.join(format!("{name}.rs"));
|
||||
let text = fs::read_to_string(&path).unwrap();
|
||||
fs::write(path, text.replace("_before", "_after")).unwrap();
|
||||
}
|
||||
let mut session = EditorSession::open(Some(root.join("first.rs")), vec![], None).unwrap();
|
||||
session.execute("diff");
|
||||
wait_until(&mut session, |session| {
|
||||
!session.editor().diff().unwrap().loading
|
||||
});
|
||||
for input in [Input::Down, Input::Enter, Input::Enter] {
|
||||
session.handle(input);
|
||||
}
|
||||
wait_until(&mut session, |session| {
|
||||
session.editor().diff().unwrap().selected_file_loaded()
|
||||
});
|
||||
session.handle(Input::Char('\t'));
|
||||
let first_origin = session.editor().cursor();
|
||||
type_query(&mut session, "/first_before");
|
||||
session.handle(Input::Enter);
|
||||
for input in [
|
||||
Input::Char(' '),
|
||||
Input::Char('e'),
|
||||
Input::Home,
|
||||
Input::Down,
|
||||
Input::Enter,
|
||||
] {
|
||||
session.handle(input);
|
||||
}
|
||||
wait_until(&mut session, |session| {
|
||||
session.editor().diff().unwrap().selected_file_loaded()
|
||||
});
|
||||
let second_origin = session.editor().cursor();
|
||||
type_query(&mut session, "/second_before");
|
||||
session.handle(Input::Enter);
|
||||
session.handle(Input::Ctrl('o'));
|
||||
assert_eq!(session.editor().cursor(), second_origin);
|
||||
session.handle(Input::Ctrl('o'));
|
||||
assert_eq!(session.editor().diff().unwrap().file, 0);
|
||||
assert_eq!(session.editor().cursor(), first_origin);
|
||||
assert!(session.editor().context().text.contains("first_before"));
|
||||
assert!(!session.editor().context().text.contains("second_before"));
|
||||
session.execute("diff.close");
|
||||
assert_eq!(
|
||||
session.editor().context().text,
|
||||
fs::read_to_string(root.join("first.rs")).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
use std::{
|
||||
fs,
|
||||
path::PathBuf,
|
||||
process::Command,
|
||||
sync::atomic::{AtomicU64, Ordering},
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use vamp::{
|
||||
diff::{DisplayRow, Focus, Phase},
|
||||
editor::{Cursor, Editor, Input, Mode, Selection},
|
||||
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-navigation-{}-{}",
|
||||
std::process::id(),
|
||||
NEXT.fetch_add(1, Ordering::Relaxed),
|
||||
));
|
||||
fs::create_dir_all(&root).unwrap();
|
||||
Self(root)
|
||||
}
|
||||
|
||||
fn file(&self, name: &str, text: &str) -> PathBuf {
|
||||
let path = self.0.join(name);
|
||||
fs::write(&path, text).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
fn editor(&self, text: &str) -> Editor {
|
||||
let mut editor =
|
||||
Editor::open(Some(self.file("buffer.txt", text)), vec!["text".into()]).unwrap();
|
||||
editor.set_clipboard_mode("separate").unwrap();
|
||||
editor
|
||||
}
|
||||
|
||||
fn session(&self, text: &str, bindings: &str) -> EditorSession {
|
||||
let source = self.file("buffer.txt", text);
|
||||
let config = self.file(
|
||||
"config.rb",
|
||||
&format!(
|
||||
"Vamp.configure do |config|\n config.clipboard_mode = 'separate'\n{bindings}\nend\n"
|
||||
),
|
||||
);
|
||||
let session =
|
||||
EditorSession::open(Some(source), vec!["text".into()], Some(&config)).unwrap();
|
||||
assert!(session.editor().startup_diagnostic().is_none());
|
||||
session
|
||||
}
|
||||
|
||||
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", "Navigation Fixture")
|
||||
.env("GIT_AUTHOR_EMAIL", "fixture@example.invalid")
|
||||
.env("GIT_COMMITTER_NAME", "Navigation 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 keys(editor: &mut Editor, text: &str) {
|
||||
for ch in text.chars() {
|
||||
editor.handle(Input::Char(ch));
|
||||
}
|
||||
}
|
||||
|
||||
fn session_keys(session: &mut EditorSession, text: &str) {
|
||||
for ch in text.chars() {
|
||||
session.handle(Input::Char(ch));
|
||||
}
|
||||
}
|
||||
|
||||
fn palette_seek(session: &mut EditorSession, action: &str, target: char) {
|
||||
session.handle(Input::Ctrl('p'));
|
||||
session_keys(session, action);
|
||||
session.handle(Input::Enter);
|
||||
assert!(session.editor().palette().is_none());
|
||||
session.handle(Input::Char(target));
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn counted_character_seeks_move_every_native_caret_independently() {
|
||||
let fixture = Fixture::new();
|
||||
let text = "xaxbx tail\nx---x----x tail\nx only\n";
|
||||
let mut editor = fixture.editor(text);
|
||||
editor.handle(Input::Ctrl('e'));
|
||||
editor.handle(Input::Ctrl('e'));
|
||||
assert_eq!(editor.selection_count(), 3);
|
||||
|
||||
keys(&mut editor, "2fx");
|
||||
assert_eq!(
|
||||
editor.carets(),
|
||||
[
|
||||
Cursor { row: 0, column: 4 },
|
||||
Cursor { row: 1, column: 9 },
|
||||
Cursor { row: 2, column: 0 },
|
||||
]
|
||||
);
|
||||
keys(&mut editor, "2Fx");
|
||||
assert_eq!(
|
||||
editor.carets(),
|
||||
[
|
||||
Cursor { row: 0, column: 0 },
|
||||
Cursor { row: 1, column: 0 },
|
||||
Cursor { row: 2, column: 0 },
|
||||
]
|
||||
);
|
||||
assert_eq!(editor.context().text, text);
|
||||
assert!(!editor.dirty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visual_character_and_line_seeks_yank_the_selected_text_into_another_buffer() {
|
||||
for (mode, expected) in [('v', "a x x"), ('V', "a x xx tail\n")] {
|
||||
let fixture = Fixture::new();
|
||||
let mut editor = fixture.editor("a x xx tail\n");
|
||||
editor.handle(Input::Char(mode));
|
||||
keys(&mut editor, "2fx");
|
||||
assert_eq!(editor.mode(), Mode::Visual);
|
||||
assert_eq!(editor.cursor(), Cursor { row: 0, column: 4 });
|
||||
assert_eq!(editor.visual_linewise(), mode == 'V');
|
||||
assert_eq!(
|
||||
editor.selections(),
|
||||
[Selection {
|
||||
start: Cursor { row: 0, column: 0 },
|
||||
end: if mode == 'V' {
|
||||
Cursor { row: 1, column: 0 }
|
||||
} else {
|
||||
Cursor { row: 0, column: 5 }
|
||||
},
|
||||
}]
|
||||
);
|
||||
editor.handle(Input::Char('y'));
|
||||
assert_eq!(editor.mode(), Mode::Normal);
|
||||
assert!(!editor.dirty());
|
||||
|
||||
let destination = fixture.file("destination.txt", "");
|
||||
editor.execute(&format!("edit {}", destination.display()));
|
||||
editor.handle(Input::Char('P'));
|
||||
assert_eq!(editor.context().text, expected, "{mode}");
|
||||
assert_eq!(
|
||||
fs::read_to_string(fixture.0.join("buffer.txt")).unwrap(),
|
||||
"a x xx tail\n"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_ruby_seek_remaps_and_palette_actions_preserve_normal_and_visual_navigation() {
|
||||
let fixture = Fixture::new();
|
||||
let text = "a x x tail\n";
|
||||
let mut session = fixture.session(
|
||||
text,
|
||||
r#" config.bind 'leader s', config.core.seek_forward
|
||||
config.bind 'leader r', config.core.seek_backward
|
||||
config.bind 'leader s', config.core.seek_forward, mode: config.modes.visual
|
||||
config.bind 'leader r', config.core.seek_backward, mode: config.modes.visual"#,
|
||||
);
|
||||
session_keys(&mut session, "2 sx");
|
||||
assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 4 });
|
||||
session_keys(&mut session, " rx");
|
||||
assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 2 });
|
||||
|
||||
session_keys(&mut session, "v sx");
|
||||
let expanded = [Selection {
|
||||
start: Cursor { row: 0, column: 2 },
|
||||
end: Cursor { row: 0, column: 5 },
|
||||
}];
|
||||
assert_eq!(session.editor().selections(), expanded);
|
||||
session_keys(&mut session, " rx");
|
||||
let contracted = [Selection {
|
||||
start: Cursor { row: 0, column: 2 },
|
||||
end: Cursor { row: 0, column: 3 },
|
||||
}];
|
||||
assert_eq!(session.editor().selections(), contracted);
|
||||
palette_seek(&mut session, "core.seek_forward", 'x');
|
||||
assert_eq!(session.editor().mode(), Mode::Visual);
|
||||
assert_eq!(session.editor().selections(), expanded);
|
||||
palette_seek(&mut session, "core.seek_backward", 'x');
|
||||
assert_eq!(session.editor().selections(), contracted);
|
||||
|
||||
session.handle(Input::Escape);
|
||||
palette_seek(&mut session, "core.seek_forward", 'x');
|
||||
assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 4 });
|
||||
palette_seek(&mut session, "core.seek_backward", 'x');
|
||||
assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 2 });
|
||||
assert_eq!(session.editor().mode(), Mode::Normal);
|
||||
assert_eq!(session.editor().context().text, text);
|
||||
assert!(!session.editor().dirty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeating_a_visual_seek_change_resolves_the_counted_character_on_the_new_line() {
|
||||
let fixture = Fixture::new();
|
||||
let mut session = fixture.session(
|
||||
"a x x tail\nlong x gap x tail\n",
|
||||
" config.bind 'leader s', config.core.seek_forward, mode: config.modes.visual",
|
||||
);
|
||||
session_keys(&mut session, "v2 sxc!");
|
||||
session.handle(Input::Escape);
|
||||
assert_eq!(
|
||||
session.editor().context().text,
|
||||
"! tail\nlong x gap x tail\n"
|
||||
);
|
||||
|
||||
session_keys(&mut session, "j0.");
|
||||
assert_eq!(session.editor().context().text, "! tail\n! tail\n");
|
||||
assert_eq!(session.editor().mode(), Mode::Normal);
|
||||
session.handle(Input::Char('u'));
|
||||
assert_eq!(
|
||||
session.editor().context().text,
|
||||
"! tail\nlong x gap x tail\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readonly_before_and_historical_after_seeks_preserve_snapshots_and_folded_excerpts() {
|
||||
let fixture = Fixture::new();
|
||||
fs::create_dir_all(fixture.0.join("repo")).unwrap();
|
||||
fixture.git(&["init", "-q"]);
|
||||
let source = fixture.0.join("repo/context.txt");
|
||||
let mut lines = (1..=40)
|
||||
.map(|row| format!("stable_{row:03} [x] [x]"))
|
||||
.collect::<Vec<_>>();
|
||||
lines[19] = "before x x tail".into();
|
||||
let before_text = lines.join("\n") + "\n";
|
||||
fs::write(&source, &before_text).unwrap();
|
||||
fixture.git(&["add", "context.txt"]);
|
||||
fixture.git(&["commit", "-qm", "before"]);
|
||||
let before = fixture.git(&["rev-parse", "HEAD"]);
|
||||
lines[19] = "after x x tail".into();
|
||||
let after_text = lines.join("\n") + "\n";
|
||||
fs::write(&source, &after_text).unwrap();
|
||||
fixture.git(&["commit", "-qam", "after"]);
|
||||
let after = fixture.git(&["rev-parse", "HEAD"]);
|
||||
lines[19] = "working x x tail".into();
|
||||
let working_text = lines.join("\n") + "\n";
|
||||
fs::write(&source, &working_text).unwrap();
|
||||
let config = fixture.file(
|
||||
"config.rb",
|
||||
"Vamp.configure do |config|\n config.clipboard_mode = 'separate'\n config.diff_context_lines = 1\n config.diff_expand_lines = 2\nend\n",
|
||||
);
|
||||
let mut session =
|
||||
EditorSession::open(Some(source.clone()), vec!["text".into()], Some(&config)).unwrap();
|
||||
assert!(session.editor().startup_diagnostic().is_none());
|
||||
session_keys(&mut session, "iunsaved ");
|
||||
session.handle(Input::Escape);
|
||||
let unsaved_text = session.editor().context().text;
|
||||
let original_cursor = session.editor().cursor();
|
||||
|
||||
session.execute("diff");
|
||||
wait_until(&mut session, |session| {
|
||||
session
|
||||
.editor()
|
||||
.diff()
|
||||
.is_some_and(|diff| diff.catalog.is_some() && !diff.loading)
|
||||
});
|
||||
for revision in [&before, &after] {
|
||||
session_keys(&mut session, revision);
|
||||
session.handle(Input::Down);
|
||||
session.handle(Input::Enter);
|
||||
}
|
||||
wait_until(&mut session, |session| {
|
||||
session
|
||||
.editor()
|
||||
.diff()
|
||||
.is_some_and(|diff| diff.phase == Phase::View && diff.selected_file_loaded())
|
||||
});
|
||||
let excerpts = session.editor().diff().unwrap().display_rows.clone();
|
||||
assert!(
|
||||
excerpts
|
||||
.iter()
|
||||
.any(|row| matches!(row, DisplayRow::Fold { .. }))
|
||||
);
|
||||
|
||||
for (focus, text, first_x, second_x) in [
|
||||
(Focus::Before, &before_text, 7, 9),
|
||||
(Focus::After, &after_text, 6, 8),
|
||||
] {
|
||||
for _ in 0..3 {
|
||||
if session.editor().diff().unwrap().focus == focus {
|
||||
break;
|
||||
}
|
||||
session.handle(Input::Char('\t'));
|
||||
}
|
||||
assert_eq!(session.editor().diff().unwrap().focus, focus);
|
||||
let revision = session.editor().text_revision();
|
||||
session_keys(&mut session, "gt02f[");
|
||||
assert_eq!(
|
||||
session.editor().cursor(),
|
||||
Cursor {
|
||||
row: 18,
|
||||
column: 15
|
||||
}
|
||||
);
|
||||
session_keys(&mut session, "F[f]");
|
||||
assert_eq!(
|
||||
session.editor().cursor(),
|
||||
Cursor {
|
||||
row: 18,
|
||||
column: 13
|
||||
}
|
||||
);
|
||||
session_keys(&mut session, "j02fx");
|
||||
assert_eq!(
|
||||
session.editor().cursor(),
|
||||
Cursor {
|
||||
row: 19,
|
||||
column: second_x
|
||||
}
|
||||
);
|
||||
session_keys(&mut session, "Fxvfx");
|
||||
assert_eq!(
|
||||
session.editor().selections(),
|
||||
[Selection {
|
||||
start: Cursor {
|
||||
row: 19,
|
||||
column: first_x
|
||||
},
|
||||
end: Cursor {
|
||||
row: 19,
|
||||
column: second_x + 1
|
||||
},
|
||||
}]
|
||||
);
|
||||
session_keys(&mut session, "yVfx");
|
||||
assert!(session.editor().visual_linewise());
|
||||
assert_eq!(
|
||||
session.editor().cursor(),
|
||||
Cursor {
|
||||
row: 19,
|
||||
column: second_x
|
||||
}
|
||||
);
|
||||
session.handle(Input::Escape);
|
||||
assert_eq!(session.editor().context().text, *text);
|
||||
assert_eq!(session.editor().text_revision(), revision);
|
||||
assert!(!session.editor().dirty());
|
||||
assert_eq!(session.editor().diff().unwrap().display_rows, excerpts);
|
||||
}
|
||||
|
||||
session.execute("diff.close");
|
||||
assert_eq!(session.editor().context().text, unsaved_text);
|
||||
assert_eq!(session.editor().cursor(), original_cursor);
|
||||
assert!(session.editor().dirty());
|
||||
assert_eq!(fs::read_to_string(&source).unwrap(), working_text);
|
||||
assert_eq!(fixture.git(&["rev-parse", "HEAD"]), after);
|
||||
}
|
||||
+185
-64
@@ -127,6 +127,26 @@ fn working(kind: RepoKind) -> Revision {
|
||||
r.target = RevisionTarget::Working;
|
||||
r
|
||||
}
|
||||
|
||||
fn compare_loaded(
|
||||
repository: &mut Repository,
|
||||
before: &Revision,
|
||||
after: &Revision,
|
||||
buffers: &[(PathBuf, String)],
|
||||
) -> Result<Comparison, String> {
|
||||
let mut comparison = repository.compare(1, before, after, buffers)?;
|
||||
for (index, file) in comparison.files.iter_mut().enumerate() {
|
||||
*file = repository.load_file(1, index)?;
|
||||
if file
|
||||
.notice
|
||||
.as_deref()
|
||||
.is_some_and(|notice| notice.contains("Binary or non-UTF-8"))
|
||||
{
|
||||
comparison.editable_paths.remove(&file.path);
|
||||
}
|
||||
}
|
||||
Ok(comparison)
|
||||
}
|
||||
fn changed<'a>(comparison: &'a Comparison, path: &Path) -> &'a vamp::diff::ChangedFile {
|
||||
comparison
|
||||
.files
|
||||
@@ -236,9 +256,13 @@ fn git_working_reads_disk_and_handles_ranges_ignored_files_binary_links_and_odd_
|
||||
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 comparison = compare_loaded(
|
||||
&mut repository,
|
||||
&revision(&id, RepoKind::Git),
|
||||
&working(RepoKind::Git),
|
||||
&[],
|
||||
)
|
||||
.unwrap();
|
||||
let text = changed(&comparison, Path::new("text"));
|
||||
assert_eq!(comparison.root, fs::canonicalize(&root).unwrap());
|
||||
assert!(comparison.editable_paths.contains(Path::new("text")));
|
||||
@@ -343,9 +367,13 @@ fn git_working_reads_disk_and_handles_ranges_ignored_files_binary_links_and_odd_
|
||||
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();
|
||||
let reverse = compare_loaded(
|
||||
&mut repository,
|
||||
&working(RepoKind::Git),
|
||||
&revision(&id, RepoKind::Git),
|
||||
&[],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(reverse.editable_paths.is_empty());
|
||||
assert_eq!(
|
||||
changed(&reverse, Path::new("added")).status,
|
||||
@@ -353,7 +381,7 @@ fn git_working_reads_disk_and_handles_ranges_ignored_files_binary_links_and_odd_
|
||||
);
|
||||
assert!(
|
||||
repository
|
||||
.compare(&working(RepoKind::Git), &working(RepoKind::Git), &[])
|
||||
.compare(1, &working(RepoKind::Git), &working(RepoKind::Git), &[])
|
||||
.unwrap()
|
||||
.files
|
||||
.is_empty()
|
||||
@@ -397,7 +425,7 @@ fn git_remote_refs_and_annotated_tags_are_discovered_before_fetch_and_history_st
|
||||
);
|
||||
let state = disk_state(&active);
|
||||
let mut repository = open(&active);
|
||||
let catalog = repository.catalog().unwrap();
|
||||
let catalog = repository.list_remote("origin").unwrap();
|
||||
let remote = catalog
|
||||
.entries
|
||||
.iter()
|
||||
@@ -439,24 +467,23 @@ fn git_remote_refs_and_annotated_tags_are_discovered_before_fetch_and_history_st
|
||||
.status
|
||||
.success()
|
||||
);
|
||||
let comparison = repository
|
||||
.compare(
|
||||
&revision(&local, RepoKind::Git),
|
||||
&remote,
|
||||
&[(active.join("file"), "unsaved local text\n".into())],
|
||||
)
|
||||
.unwrap();
|
||||
let comparison = compare_loaded(
|
||||
&mut repository,
|
||||
&revision(&local, RepoKind::Git),
|
||||
&remote,
|
||||
&[(active.join("file"), "unsaved local text\n".into())],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(comparison.editable_paths.is_empty());
|
||||
assert_eq!(changed(&comparison, Path::new("file")).before, ["local"]);
|
||||
assert_eq!(
|
||||
changed(&comparison, Path::new("file")).after,
|
||||
["remote tip"]
|
||||
);
|
||||
let comparison = repository.compare(&tag, &remote, &[]).unwrap();
|
||||
let comparison = compare_loaded(&mut repository, &tag, &remote, &[]).unwrap();
|
||||
assert_eq!(changed(&comparison, Path::new("file")).before, ["ancestor"]);
|
||||
let comparison = repository
|
||||
.compare(&remote, &working(RepoKind::Git), &[])
|
||||
.unwrap();
|
||||
let comparison =
|
||||
compare_loaded(&mut repository, &remote, &working(RepoKind::Git), &[]).unwrap();
|
||||
assert_eq!(changed(&comparison, Path::new("file")).after, ["local"]);
|
||||
let loaded = repository.fetch_remote("origin").unwrap();
|
||||
let historic = loaded
|
||||
@@ -468,9 +495,13 @@ fn git_remote_refs_and_annotated_tags_are_discovered_before_fetch_and_history_st
|
||||
.unwrap();
|
||||
assert_eq!(historic.label, ancestor);
|
||||
assert_eq!(historic.description, "remote ancestor needle");
|
||||
let comparison = repository
|
||||
.compare(historic, &revision(&local, RepoKind::Git), &[])
|
||||
.unwrap();
|
||||
let comparison = compare_loaded(
|
||||
&mut repository,
|
||||
historic,
|
||||
&revision(&local, RepoKind::Git),
|
||||
&[],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(changed(&comparison, Path::new("file")).before, ["ancestor"]);
|
||||
assert!(
|
||||
!command("git", &active, &["cat-file", "-e", &tip])
|
||||
@@ -541,9 +572,8 @@ fn jj_catalog_and_working_comparisons_prefer_jj_and_never_snapshot_or_merge_oper
|
||||
.iter()
|
||||
.any(|r| r.label == "baseline" && r.category == Category::Bookmarks)
|
||||
);
|
||||
let comparison = repository
|
||||
.compare(baseline, &working(RepoKind::Jujutsu), &[])
|
||||
.unwrap();
|
||||
let comparison =
|
||||
compare_loaded(&mut repository, baseline, &working(RepoKind::Jujutsu), &[]).unwrap();
|
||||
assert_eq!(changed(&comparison, Path::new("file")).after, ["disk"]);
|
||||
assert_eq!(
|
||||
changed(&comparison, Path::new("deleted")).status,
|
||||
@@ -644,7 +674,7 @@ fn jj_private_fetch_discovers_never_fetched_bookmark_and_searchable_remote_chang
|
||||
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 catalog = repository.list_remote("origin").unwrap();
|
||||
let remote = catalog
|
||||
.entries
|
||||
.iter()
|
||||
@@ -663,9 +693,8 @@ fn jj_private_fetch_discovers_never_fetched_bookmark_and_searchable_remote_chang
|
||||
.iter()
|
||||
.any(|r| r.target == RevisionTarget::Commit(ancestor.clone()))
|
||||
);
|
||||
let comparison = repository
|
||||
.compare(&remote, &working(RepoKind::Jujutsu), &[])
|
||||
.unwrap();
|
||||
let comparison =
|
||||
compare_loaded(&mut repository, &remote, &working(RepoKind::Jujutsu), &[]).unwrap();
|
||||
assert_eq!(
|
||||
changed(&comparison, Path::new("file")).before,
|
||||
["remote tip"]
|
||||
@@ -682,7 +711,7 @@ fn jj_private_fetch_discovers_never_fetched_bookmark_and_searchable_remote_chang
|
||||
.unwrap();
|
||||
assert!(historic.label.contains(&ancestor) && historic.label.contains(&change));
|
||||
assert_eq!(historic.description, "remote historic needle");
|
||||
let comparison = repository.compare(historic, &remote, &[]).unwrap();
|
||||
let comparison = compare_loaded(&mut repository, historic, &remote, &[]).unwrap();
|
||||
assert_eq!(
|
||||
changed(&comparison, Path::new("file")).before,
|
||||
["remote ancestor"]
|
||||
@@ -735,7 +764,14 @@ fn errors_are_explicit_and_do_not_modify_active_repository() {
|
||||
let state = disk_state(&root);
|
||||
let mut repository = open(&root);
|
||||
let catalog = repository.catalog().unwrap();
|
||||
assert!(catalog.notice.unwrap().contains("broken"));
|
||||
assert!(catalog.notice.is_none());
|
||||
assert!(
|
||||
catalog
|
||||
.entries
|
||||
.iter()
|
||||
.any(|revision| revision.target == RevisionTarget::ListRemote("broken".into()))
|
||||
);
|
||||
assert!(repository.list_remote("broken").is_err());
|
||||
assert!(repository.fetch_remote("broken").is_err());
|
||||
assert!(
|
||||
repository
|
||||
@@ -746,6 +782,7 @@ fn errors_are_explicit_and_do_not_modify_active_repository() {
|
||||
assert!(
|
||||
repository
|
||||
.compare(
|
||||
1,
|
||||
&revision("--bad", RepoKind::Git),
|
||||
&working(RepoKind::Git),
|
||||
&[]
|
||||
@@ -756,6 +793,7 @@ fn errors_are_explicit_and_do_not_modify_active_repository() {
|
||||
assert!(
|
||||
repository
|
||||
.compare(
|
||||
1,
|
||||
&revision(&"f".repeat(40), RepoKind::Git),
|
||||
&working(RepoKind::Git),
|
||||
&[],
|
||||
@@ -769,7 +807,7 @@ fn errors_are_explicit_and_do_not_modify_active_repository() {
|
||||
};
|
||||
assert!(
|
||||
repository
|
||||
.compare(&loader, &revision(&id, RepoKind::Git), &[])
|
||||
.compare(1, &loader, &revision(&id, RepoKind::Git), &[])
|
||||
.unwrap_err()
|
||||
.contains("history loader")
|
||||
);
|
||||
@@ -810,6 +848,7 @@ fn unchanged_submodules_are_not_false_changes_and_changed_pointers_are_explicit(
|
||||
assert!(
|
||||
repository
|
||||
.compare(
|
||||
1,
|
||||
&revision(&baseline, RepoKind::Git),
|
||||
&working(RepoKind::Git),
|
||||
&[]
|
||||
@@ -824,13 +863,13 @@ fn unchanged_submodules_are_not_false_changes_and_changed_pointers_are_explicit(
|
||||
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 comparison = compare_loaded(
|
||||
&mut repository,
|
||||
&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();
|
||||
@@ -897,13 +936,13 @@ fn multiple_remotes_retain_tag_only_history_even_when_tag_names_collide() {
|
||||
&& r.description == format!("{name} tag-only needle"))
|
||||
);
|
||||
}
|
||||
let comparison = repository
|
||||
.compare(
|
||||
&revision(&ids[0], RepoKind::Git),
|
||||
&revision(&ids[1], RepoKind::Git),
|
||||
&[],
|
||||
)
|
||||
.unwrap();
|
||||
let comparison = compare_loaded(
|
||||
&mut repository,
|
||||
&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);
|
||||
@@ -1011,13 +1050,13 @@ fn jj_conflicted_revisions_show_cli_projection_and_keep_ordinary_files_navigable
|
||||
.iter()
|
||||
.any(|r| r.target == RevisionTarget::Commit(merged.clone()))
|
||||
);
|
||||
let comparison = repository
|
||||
.compare(
|
||||
&revision(&base, RepoKind::Jujutsu),
|
||||
&revision(&merged, RepoKind::Jujutsu),
|
||||
&[],
|
||||
)
|
||||
.unwrap();
|
||||
let comparison = compare_loaded(
|
||||
&mut repository,
|
||||
&revision(&base, RepoKind::Jujutsu),
|
||||
&revision(&merged, RepoKind::Jujutsu),
|
||||
&[],
|
||||
)
|
||||
.unwrap();
|
||||
let conflict = changed(&comparison, Path::new("conflict"));
|
||||
assert_eq!(conflict.after, expected);
|
||||
assert!(
|
||||
@@ -1032,13 +1071,13 @@ fn jj_conflicted_revisions_show_cli_projection_and_keep_ordinary_files_navigable
|
||||
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();
|
||||
let working_comparison = compare_loaded(
|
||||
&mut repository,
|
||||
&revision(&merged, RepoKind::Jujutsu),
|
||||
&working(RepoKind::Jujutsu),
|
||||
&[],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
changed(&working_comparison, Path::new("conflict"))
|
||||
.notice
|
||||
@@ -1079,7 +1118,7 @@ fn unsaved_overlays_are_working_after_only_and_cannot_follow_nonregular_paths()
|
||||
let mut repository = open(&root.join("sub"));
|
||||
let base = revision(&id, RepoKind::Git);
|
||||
let checkout = working(RepoKind::Git);
|
||||
let comparison = repository.compare(&base, &checkout, &buffers).unwrap();
|
||||
let comparison = compare_loaded(&mut repository, &base, &checkout, &buffers).unwrap();
|
||||
assert_eq!(comparison.root, fs::canonicalize(&root).unwrap());
|
||||
assert_eq!(changed(&comparison, Path::new("file")).after, ["unsaved"]);
|
||||
assert!(comparison.editable_paths.contains(Path::new("file")));
|
||||
@@ -1101,7 +1140,7 @@ fn unsaved_overlays_are_working_after_only_and_cannot_follow_nonregular_paths()
|
||||
|| file.path == Path::new("missing")
|
||||
|| file.path == Path::new("linked/file"))
|
||||
);
|
||||
let immutable = repository.compare(&checkout, &base, &buffers).unwrap();
|
||||
let immutable = compare_loaded(&mut repository, &checkout, &base, &buffers).unwrap();
|
||||
assert!(immutable.editable_paths.is_empty());
|
||||
assert!(
|
||||
!immutable
|
||||
@@ -1111,12 +1150,12 @@ fn unsaved_overlays_are_working_after_only_and_cannot_follow_nonregular_paths()
|
||||
);
|
||||
assert!(
|
||||
repository
|
||||
.compare(&base, &base, &buffers)
|
||||
.compare(1, &base, &base, &buffers)
|
||||
.unwrap()
|
||||
.files
|
||||
.is_empty()
|
||||
);
|
||||
let same_checkout = repository.compare(&checkout, &checkout, &buffers).unwrap();
|
||||
let same_checkout = compare_loaded(&mut repository, &checkout, &checkout, &buffers).unwrap();
|
||||
assert_eq!(changed(&same_checkout, Path::new("file")).before, ["base"]);
|
||||
assert_eq!(
|
||||
changed(&same_checkout, Path::new("file")).after,
|
||||
@@ -1128,3 +1167,85 @@ fn unsaved_overlays_are_working_after_only_and_cannot_follow_nonregular_paths()
|
||||
"outside\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn immutable_catalog_skips_unchanged_blobs_and_prepares_only_requested_details() {
|
||||
let fixture = Fixture::new();
|
||||
let root = fixture.dir("lazy");
|
||||
init_git(&root);
|
||||
fs::write(root.join("unchanged-large"), vec![b'x'; 17 * 1024 * 1024]).unwrap();
|
||||
fs::write(root.join("selected"), "before\n").unwrap();
|
||||
fs::write(root.join("unselected"), "old\n").unwrap();
|
||||
git(&root, &["add", "."]);
|
||||
git(&root, &["commit", "-m", "before"]);
|
||||
let before = git(&root, &["rev-parse", "HEAD"]);
|
||||
fs::write(root.join("selected"), "after\n").unwrap();
|
||||
fs::write(root.join("unselected"), vec![b'y'; 17 * 1024 * 1024]).unwrap();
|
||||
git(&root, &["add", "."]);
|
||||
git(&root, &["commit", "-m", "after"]);
|
||||
let after = git(&root, &["rev-parse", "HEAD"]);
|
||||
let mut repository = open(&root);
|
||||
let comparison = repository
|
||||
.compare(
|
||||
100,
|
||||
&revision(&before, RepoKind::Git),
|
||||
&revision(&after, RepoKind::Git),
|
||||
&[],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(comparison.files.len(), 2);
|
||||
assert!(comparison.files.iter().all(|file| !file.loaded
|
||||
&& file.before.is_empty()
|
||||
&& file.after.is_empty()
|
||||
&& file.hunks.is_empty()));
|
||||
let index = comparison
|
||||
.files
|
||||
.iter()
|
||||
.position(|file| file.path == Path::new("selected"))
|
||||
.unwrap();
|
||||
let selected = repository.load_file(100, index).unwrap();
|
||||
assert!(selected.loaded);
|
||||
assert_eq!(selected.before, ["before"]);
|
||||
assert_eq!(selected.after, ["after"]);
|
||||
assert_eq!(selected.hunks.len(), 1);
|
||||
assert!(repository.load_file(99, index).is_err());
|
||||
let empty = repository
|
||||
.compare(
|
||||
101,
|
||||
&revision(&after, RepoKind::Git),
|
||||
&revision(&after, RepoKind::Git),
|
||||
&[],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(empty.files.is_empty());
|
||||
assert!(repository.load_file(100, index).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deferred_file_details_keep_frozen_checkout_and_unsaved_after() {
|
||||
let fixture = Fixture::new();
|
||||
let root = fixture.dir("frozen").canonicalize().unwrap();
|
||||
init_git(&root);
|
||||
fs::write(root.join("file"), "committed\n").unwrap();
|
||||
git(&root, &["add", "."]);
|
||||
git(&root, &["commit", "-m", "before"]);
|
||||
fs::write(root.join("file"), "disk before\n").unwrap();
|
||||
let mut repository = open(&root);
|
||||
let comparison = repository
|
||||
.compare(
|
||||
50,
|
||||
&working(RepoKind::Git),
|
||||
&working(RepoKind::Git),
|
||||
&[(root.join("file"), "unsaved after\n".into())],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(comparison.files.len(), 1);
|
||||
assert!(!comparison.files[0].loaded);
|
||||
fs::write(root.join("file"), "later disk\n").unwrap();
|
||||
let details = repository.load_file(50, 0).unwrap();
|
||||
assert_eq!(details.before, ["disk before"]);
|
||||
assert_eq!(details.after, ["unsaved after"]);
|
||||
let again = repository.load_file(50, 0).unwrap();
|
||||
assert_eq!(again.before, details.before);
|
||||
assert_eq!(again.after, details.after);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user