Compare commits
10 Commits
c88582f2ba
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2368091d93 | |||
| 1afc896cab | |||
| e3d44f24c0 | |||
| 496c93774f | |||
| c6901eaaa6 | |||
| ce434a1c17 | |||
| 846803a3e2 | |||
| 448c3bf477 | |||
| 815fcccbe8 | |||
| c1bff63111 |
@@ -26,9 +26,15 @@ vamp --review resolve 1 3 --repo . # Mark comment 3 resolved, preserving its fee
|
|||||||
|
|
||||||
The review CLI discovers the Git/Jujutsu checkout from `--repo` (default: current directory). It uses the same default configuration, `--config PATH`, `--no-config`, and `review_directory` as the editor, but starts no editor workers or terminal UI. Missing storage lists no reviews without creating a database; incompatible storage is rejected and preserved.
|
The review CLI discovers the Git/Jujutsu checkout from `--repo` (default: current directory). It uses the same default configuration, `--config PATH`, `--no-config`, and `review_directory` as the editor, but starts no editor workers or terminal UI. Missing storage lists no reviews without creating a database; incompatible storage is rejected and preserved.
|
||||||
|
|
||||||
|
Local reviews are scoped to the canonical checkout and ordered, resolved Before/After pair. Opening a different comparison selects its own review; reopening the same pair resumes its feedback and progress. In-place Working refreshes retain the active review. Ordinary source-buffer comments use separate source reviews. CLI listings identify each review's scope; JSON exposes it as `comparison` (an endpoint pair, or `null` for source reviews).
|
||||||
|
|
||||||
|
Review storage now uses SQLite schema 4 and JSON export version 5. Older formats are rejected and preserved without migration or reset.
|
||||||
|
|
||||||
Use the persistent **Review ID** printed by `list`, not a list position. IDs are shared across repositories; listing filters them to the selected checkout, so its first review need not be ID 1. Human-readable listings include a command to show each review. The internal save revision is omitted from text but remains in JSON; it is not a Git/Jujutsu revision. `show all` is not supported: use `show ID --all` to include resolved comments in that review.
|
Use the persistent **Review ID** printed by `list`, not a list position. IDs are shared across repositories; listing filters them to the selected checkout, so its first review need not be ID 1. Human-readable listings include a command to show each review. The internal save revision is omitted from text but remains in JSON; it is not a Git/Jujutsu revision. `show all` is not supported: use `show ID --all` to include resolved comments in that review.
|
||||||
|
|
||||||
`show` includes unresolved comments by default; `--all` includes resolved comments too. Human-readable output is the default. JSON includes the canonical repository root, storage review ID/revision, summary/verdict, total/resolved/unresolved counts, and comments with exact zero-based, end-exclusive grapheme anchors and selected source excerpts—not whole source files. `list --json` returns the repository and review metadata/counts without comments. Explicit editor JSON exports still contain complete anchor source text.
|
`show` includes unresolved comments by default; `--all` includes resolved comments too. Human-readable output is the default, with source excerpts in language-tagged Markdown code blocks. JSON includes the canonical repository root, storage review ID/revision, summary/verdict, total/resolved/unresolved counts, and comments with exact zero-based, end-exclusive grapheme anchors and raw selected source excerpts—not whole source files or Markdown fences. `list --json` returns the repository and review metadata/counts without comments. Explicit editor JSON exports still contain complete anchor source text.
|
||||||
|
|
||||||
|
`list` omits reviews with no comments in both text and JSON output. Reviews containing only resolved comments remain listed. Commentless reviews remain stored to preserve file checkmarks, summaries, and verdicts; the editor can still resume them, and `show ID` can inspect them directly.
|
||||||
|
|
||||||
`list` and `show` are read-only. `delete REVIEW_ID COMMENT_ID` immediately commits deletion of one resolved or unresolved comment. `resolve REVIEW_ID COMMENT_ID` instead marks that comment resolved, preserving its body and original anchor. Repeating resolution succeeds without writing or advancing the review revision. Both commands print the updated review with its remaining unresolved comments (`--json` is supported); use `show REVIEW_ID --all` to verify resolved feedback. Both IDs are required; missing reviews/comments and repository mismatches fail without changing stored reviews or creating missing storage. Deleting the last comment keeps the review, summary, and verdict. Concurrent saves use the same revision-conflict protection as the editor; an already-open editor must reload before saving after a CLI change. Existing JSON exports are not modified.
|
`list` and `show` are read-only. `delete REVIEW_ID COMMENT_ID` immediately commits deletion of one resolved or unresolved comment. `resolve REVIEW_ID COMMENT_ID` instead marks that comment resolved, preserving its body and original anchor. Repeating resolution succeeds without writing or advancing the review revision. Both commands print the updated review with its remaining unresolved comments (`--json` is supported); use `show REVIEW_ID --all` to verify resolved feedback. Both IDs are required; missing reviews/comments and repository mismatches fail without changing stored reviews or creating missing storage. Deleting the last comment keeps the review, summary, and verdict. Concurrent saves use the same revision-conflict protection as the editor; an already-open editor must reload before saving after a CLI change. Existing JSON exports are not modified.
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
- [x] improve: diff picker - if jj managed, present as jj log.
|
||||||
|
- this makes it much easier to determine which revisions to actually pick for before/after
|
||||||
|
- the bookmarks only view could simply filter out unbookmarked revisions but otherwise display in the same way
|
||||||
- [ ] feature: diff view - GitHub code review integration after the local review UX is usable.
|
- [ ] feature: diff view - GitHub code review integration after the local review UX is usable.
|
||||||
- publish local reviews explicitly: general PR comments, inline line/range comments, review summaries, approval, and requests for changes.
|
- publish local reviews explicitly: general PR comments, inline line/range comments, review summaries, approval, and requests for changes.
|
||||||
- preserve explicit PR identity and reviewed commit IDs; validate anchors against GitHub's PR diff and recheck the PR head before submission.
|
- preserve explicit PR identity and reviewed commit IDs; validate anchors against GitHub's PR diff and recheck the PR head before submission.
|
||||||
|
|||||||
@@ -17,10 +17,11 @@ One editor crate; the workspace also includes its patched Crossterm dependency.
|
|||||||
Virtual diff leaves retain their original catalog input index for caller-owned annotations. Normal scanned leaves accept optional repository status and old-path metadata without filtering unchanged files; missing tracked deletions add virtual leaves and required directories. Metadata-only updates preserve query, selection, expansion, and view without rebuilding the catalog.
|
Virtual diff leaves retain their original catalog input index for caller-owned annotations. Normal scanned leaves accept optional repository status and old-path metadata without filtering unchanged files; missing tracked deletions add virtual leaves and required directories. Metadata-only updates preserve query, selection, expansion, and view without rebuilding the catalog.
|
||||||
- `diff`: owns searchable Before/After selection, category, focus and explorer visibility, request freshness, read-only comparison DTOs, aligned rows with cached grapheme-level change ranges, and shared vertical/independent horizontal offsets. Changed paths form a virtual `files::FilePicker` catalog, reusing the normal tree projection, filtering, and navigation without scanning disk. It has no subprocess or terminal dependencies.
|
- `diff`: owns searchable Before/After selection, category, focus and explorer visibility, request freshness, read-only comparison DTOs, aligned rows with cached grapheme-level change ranges, and shared vertical/independent horizontal offsets. Changed paths form a virtual `files::FilePicker` catalog, reusing the normal tree projection, filtering, and navigation without scanning disk. It has no subprocess or terminal dependencies.
|
||||||
- `repository`: discovers jj/Git repositories and local/advertised remote revisions, builds disk snapshots and changed-file hunks, and owns isolated temporary comparison repositories with bounded, cancellable subprocesses. It never saves editor buffers or modifies active refs/checkouts.
|
- `repository`: discovers jj/Git repositories and local/advertised remote revisions, builds disk snapshots and changed-file hunks, and owns isolated temporary comparison repositories with bounded, cancellable subprocesses. It never saves editor buffers or modifies active refs/checkouts.
|
||||||
- `review`: owns the validated local review model, exact immutable anchors, persisted file checkmarks, SQLite storage through bundled rusqlite, and atomic JSON import/export with external-change protection. Schema 3 stores review identity, repository, revision, summary, and verdict in `reviews`; `comments` stores ordered comment IDs, bodies, resolution states, and explicit immutable anchor fields. `reviewed_files` stores revision pairs, paths, optional old paths, and optional exact live After text. Foreign keys, unique keys, and checks enforce relationships and scalar invariants. Full `u64` comment IDs use checked decimal text because SQLite integers are signed. No JSON documents are stored in SQLite.
|
- `review`: owns the validated local review model, exact immutable anchors, persisted file checkmarks, SQLite storage through bundled rusqlite, and atomic JSON import/export with external-change protection. Schema 4 stores review identity, repository, revision, summary, verdict, and nullable ordered comparison endpoints in `reviews`; null endpoints identify source reviews. `comments` stores ordered comment IDs, bodies, resolution states, and explicit immutable anchor fields. `reviewed_files` stores revision pairs, paths, optional old paths, and optional exact live After text. Foreign keys, unique keys, and checks enforce relationships and scalar invariants. Full `u64` comment IDs use checked decimal text because SQLite integers are signed. No JSON documents are stored in SQLite.
|
||||||
`Store` retains compare-and-swap review revisions and atomic whole-review saves: metadata and replacement comment/checkmark rows commit together. Read transactions reconstruct validated reviews from one consistent snapshot, including when listing in descending ID order. Stored text is bounded to 64 MiB per review, including a fixed per-comment/checkmark overhead. JSON version 4 is an independent import/export format. Incompatible databases, including schemas 1 and 2, are rejected and preserved without migration or reset. CLI metadata, sole-review restoration, and selector labels reuse loaded reviews; selector choices live only in the displayed buffer.
|
`Store` retains compare-and-swap review revisions and atomic whole-review saves: metadata and replacement comment/checkmark rows commit together. A stored review cannot be reassigned to another scope. Read transactions reconstruct validated reviews from one consistent snapshot, including when listing in descending ID order. Stored text is bounded to 64 MiB per review, including a fixed per-comment/checkmark overhead. JSON version 5 is an independent import/export format. Incompatible databases, including schema 3, are rejected and preserved without migration or reset. CLI metadata and selectors reuse loaded reviews; selector choices live only in the displayed buffer.
|
||||||
The review CLI keeps `list`/`show` read-only. Comment deletion and resolution validate the explicit review/comment IDs through an existing read-only store before opening it for a compare-and-swap save; absent storage and invalid targets do not create reviews or a database. Resolution preserves comment bodies and immutable anchors; already-resolved comments succeed without writing or advancing the revision.
|
The review CLI keeps `list`/`show` read-only. Comment deletion and resolution validate the explicit review/comment IDs through an existing read-only store before opening it for a compare-and-swap save; absent storage and invalid targets do not create reviews or a database. Resolution preserves comment bodies and immutable anchors; already-resolved comments succeed without writing or advancing the revision.
|
||||||
`editor_review` owns tab-local review state independently of comparisons. Ordinary repository source buffers and diff panes share the comment editor, immutable anchors, storage, and review commands. Comment/summary text saves explicitly; dismissing discards that editor's unsaved text, while switching may retain hidden drafts. Verdicts/deletions/resolution changes commit immediately without saving drafts. It restores a sole stored review or opens the floating selector for multiple reviews. Exports retain their own file baseline and do not become automatic persistence targets; imports get fresh database identities. Review editors remain tab-owned native panes outside the workspace `PaneTree`. The session supplies the configured or XDG default directory; direct `Editor` callers inject a directory with `set_review_directory` to enable persistence.
|
CLI listings exclude reviews with zero comments, not reviews with zero unresolved comments. Filtering stays at the CLI boundary: storage listing and editor restoration retain commentless reviews and their file checkmarks, summaries, and verdicts.
|
||||||
|
`editor_review` owns tab-local review state selected by canonical repository root and ordered resolved comparison identities, or a separate source scope for ordinary repository buffers. A sole scope-matching review restores automatically; multiple matches open the floating selector. `diff` freezes initial review endpoints through in-place Working refreshes; explicit comparisons and moved bookmark endpoints resolve their own scope. Source and comparison panes share the comment editor, immutable anchors, storage, and review commands, not their review documents. Dirty drafts block scope changes, and stored/JSON opens reject mismatched scopes before replacing state. Comment/summary text saves explicitly; dismissing discards that editor's unsaved text, while switching may retain hidden drafts. Verdicts/deletions/resolution changes commit immediately without saving drafts. Exports retain their own file baseline and do not become automatic persistence targets; imports get fresh database identities. Review editors remain tab-owned native panes outside the workspace `PaneTree`. The session supplies the configured or XDG default directory; direct `Editor` callers inject a directory with `set_review_directory` to enable persistence.
|
||||||
Successful SQLite autosaves and JSON-only saves share one acknowledgement operation: clear review dirtiness and advance the owning tab's native draft saved baselines only after persistence succeeds. Failed writes retain unsaved state; JSON exports do not acknowledge a pending SQLite save.
|
Successful SQLite autosaves and JSON-only saves share one acknowledgement operation: clear review dirtiness and advance the owning tab's native draft saved baselines only after persistence succeeds. Failed writes retain unsaved state; JSON exports do not acknowledge a pending SQLite save.
|
||||||
Review replacement and comment deletion are private editor-owned operations. Replacement loads and validates its Stored/JSON/New source before retiring old bindings, installing the document, and resetting selection/navigation. Deletion retires and acknowledges only the matching comment drafts, preventing stale text from overwriting reused IDs. Both persist before opening the overview; a post-mutation save failure retains the applied, unsaved identity change. Native text/undo and general transition guards remain unchanged.
|
Review replacement and comment deletion are private editor-owned operations. Replacement loads and validates its Stored/JSON/New source before retiring old bindings, installing the document, and resetting selection/navigation. Deletion retires and acknowledges only the matching comment drafts, preventing stale text from overwriting reused IDs. Both persist before opening the overview; a post-mutation save failure retains the applied, unsaved identity change. Native text/undo and general transition guards remain unchanged.
|
||||||
- `clipboard`: owns native plain-text provider selection and bounded subprocess I/O; it never computes selections or editing offsets.
|
- `clipboard`: owns native plain-text provider selection and bounded subprocess I/O; it never computes selections or editing offsets.
|
||||||
@@ -64,7 +65,7 @@ Frontends inspect `file_picker()` for query, presentation, view, explorer-local
|
|||||||
Frontends inspect `diagnostics()` and `diagnostic_at_cursor()` for validated active-buffer annotations. Diagnostics are owned by core LSP or a plugin and buffer revision; text changes clear them. Native server retirement removes only that server's contribution, preserving sibling diagnostics. The TUI underlines annotated text, colors affected gutter numbers by severity, and displays the primary caret's diagnostic message without replacing search/selection backgrounds.
|
Frontends inspect `diagnostics()` and `diagnostic_at_cursor()` for validated active-buffer annotations. Diagnostics are owned by core LSP or a plugin and buffer revision; text changes clear them. Native server retirement removes only that server's contribution, preserving sibling diagnostics. The TUI underlines annotated text, colors affected gutter numbers by severity, and displays the primary caret's diagnostic message without replacing search/selection backgrounds.
|
||||||
Frontends borrow `semantic_tokens()` for sorted, single-line grapheme spans from the active buffer. Immutable `Arc` slices are shared by native LSP and buffer storage; painting does not copy tokens. Text replacements retain tokens outside the changed range and shift following positions using whole graphemes, including newline changes. Tokens overlapping the changed range are dropped; disjoint edits conservatively invalidate the range between their first and last changes. This applies to typing, undo/redo, completion, plugin replacements, and disk reloads. Path changes discard overlays. Native updates validate the whole applicable annotation batch before mutation. The TUI merges semantic styling into its existing glyph pass above syntax foregrounds but below diagnostic/search/selection/caret styling.
|
Frontends borrow `semantic_tokens()` for sorted, single-line grapheme spans from the active buffer. Immutable `Arc` slices are shared by native LSP and buffer storage; painting does not copy tokens. Text replacements retain tokens outside the changed range and shift following positions using whole graphemes, including newline changes. Tokens overlapping the changed range are dropped; disjoint edits conservatively invalidate the range between their first and last changes. This applies to typing, undo/redo, completion, plugin replacements, and disk reloads. Path changes discard overlays. Native updates validate the whole applicable annotation batch before mutation. The TUI merges semantic styling into its existing glyph pass above syntax foregrounds but below diagnostic/search/selection/caret styling.
|
||||||
Frontends inspect `hover()` for optional read-only content and logical-line/grapheme scroll offsets. Popup request eligibility, dismissal, and scrolling stay editor/session-owned; the TUI clips the panel inside the full editor area without changing the primary cursor or viewport. Its hover-local syntax cache treats content as Markdown, including named fenced-code injections, and rebuilds only when content changes. The popup shares the document syntax palette and preserves raw Markdown text and logical scroll coordinates.
|
Frontends inspect `hover()` for optional read-only content and logical-line/grapheme scroll offsets. Popup request eligibility, dismissal, and scrolling stay editor/session-owned; the TUI clips the panel inside the full editor area without changing the primary cursor or viewport. Its hover-local syntax cache treats content as Markdown, including named fenced-code injections, and rebuilds only when content changes. The popup shares the document syntax palette and preserves raw Markdown text and logical scroll coordinates.
|
||||||
Frontends borrow `completion()` for the read-only item list and selected index: zero is Original text, and other indices select `items[selected - 1]`. The editor owns the immutable original document/carets, atomic candidate transactions, cycling/dismissal, pending Insert undo, and final-only repeat recording. It changes only the primary completion target and translates secondary carets through all edits. The TUI places a windowed list near the current caret, bounded by the full editor rather than its pane, sanitizes/clips labels and detail by grapheme, and preserves hardware cursor ownership; wheel input over the list is consumed without dispatching edits.
|
Frontends borrow `completion()` for the read-only item list and selected index: zero is Original text, and other indices select `items[selected - 1]`. The editor owns the immutable original document/carets, atomic candidate transactions, cycling/dismissal, pending Insert undo, and final-only repeat recording. It changes only the primary completion target and translates secondary carets through all edits. The TUI places a windowed list near the current caret, bounded by the full editor rather than its pane, sanitizes/clips labels and detail by grapheme, and preserves logical cursor ownership; wheel input over the list is consumed without dispatching edits.
|
||||||
Frontends borrow `snippet_selections()` for the active argument's end-exclusive grapheme ranges, including mirrors and empty tabstops. The editor caches those ranges after previews, argument edits, and navigation; rendering adds no snippet allocation. Template edits stay primary-only, translate other carets, and fold resolved replacement text and caret placement into the pending completion repeat step. Per-occurrence ancestry distinguishes nested arguments from adjacent empty stops, allowing enclosing replacements to retire only overwritten descendants.
|
Frontends borrow `snippet_selections()` for the active argument's end-exclusive grapheme ranges, including mirrors and empty tabstops. The editor caches those ranges after previews, argument edits, and navigation; rendering adds no snippet allocation. Template edits stay primary-only, translate other carets, and fold resolved replacement text and caret placement into the pending completion repeat step. Per-occurrence ancestry distinguishes nested arguments from adjacent empty stops, allowing enclosing replacements to retire only overwritten descendants.
|
||||||
|
|
||||||
## Terminal lifecycle and layout
|
## Terminal lifecycle and layout
|
||||||
@@ -73,13 +74,13 @@ The terminal guard owns raw mode, alternate-screen state, bracketed paste, mouse
|
|||||||
|
|
||||||
`tui::TerminalSession::run(inherited_screen, body)` scopes this ownership; `tui::run(&mut session, inherited_screen)` renders inside that active guard. On Unix, a fresh alternate screen pushes disambiguated keyboard flags so Ctrl+i stays distinct from Tab; final cleanup pops them. `TerminalSession::prepare_restart()` resets process-local input state without leaving the alternate screen or popping its keyboard flags. The replacement process inherits that stack entry rather than pushing another, and the guard stays armed if relaunch fails.
|
`tui::TerminalSession::run(inherited_screen, body)` scopes this ownership; `tui::run(&mut session, inherited_screen)` renders inside that active guard. On Unix, a fresh alternate screen pushes disambiguated keyboard flags so Ctrl+i stays distinct from Tab; final cleanup pops them. `TerminalSession::prepare_restart()` resets process-local input state without leaving the alternate screen or popping its keyboard flags. The replacement process inherits that stack entry rather than pushing another, and the guard stays armed if relaunch fails.
|
||||||
|
|
||||||
Redraws stage Ratatui's buffer diff, cursor placement, and DEC synchronized-update commands (mode 2026) in a reusable byte buffer, then write and flush the complete frame to the terminal. Frame computation never suspends terminal presentation; synchronization covers transmitted painting rather than layout/highlighting work. The hardware cursor is hidden before painting even when synchronization is unsupported. Terminal cleanup releases synchronization before leaving the alternate screen or invoking caller panic diagnostics. Backend-writer access is enabled solely for this staging seam.
|
Redraws stage Ratatui's buffer diff, logical cursor placement, and DEC synchronized-update commands (mode 2026) in a reusable byte buffer, then write and flush the complete frame to the terminal. Frame computation never suspends terminal presentation; synchronization covers transmitted painting rather than layout/highlighting work. `WheelPanels::cursor` records the final focused cursor position; the software cursor is painted after overlays, using the same named ANSI mode palette as the bars. `SoftwareCursorBackend` delegates drawing and cursor coordinates but suppresses hardware-cursor visibility, retaining input-method placement without a second cursor or OSC 12 dependency. The hardware cursor stays hidden even when synchronization is unsupported. Terminal cleanup releases synchronization before leaving the alternate screen, then restores cursor visibility before returning or invoking caller panic diagnostics. Backend-writer access is enabled solely for this staging seam.
|
||||||
|
|
||||||
`terminal_colors` intercepts crossterm's key-shaped OSC 11/4 replies before editor dispatch, assembles a bounded RGB background/red/green triple, and preserves unrelated events and opaque bracketed paste. Queries never block input and start after painting, so syntax initialization does not spend their reply deadline. Startup, color-scheme notifications, and focus-regain queries replace cached diff tints atomically. While any rendered pane shows a diff, a one-second fallback probes for silent palette changes or missed notifications; ordinary editor views do not poll. Its injected-time deadline prevents overlapping periodic requests. An unanswered/partial query expires after 250 ms and removes stale tints rather than assuming a dark theme. Valid late replies are consumed without becoming editing commands. The frontend precomputes rounded RGB blends from the original background (18% row, 38% character), propagating them to active, parked, and newly created panes.
|
`terminal_colors` intercepts crossterm's key-shaped OSC 11/4 replies before editor dispatch, assembles a bounded RGB background/red/green triple, and preserves unrelated events and opaque bracketed paste. Queries never block input and start after painting, so syntax initialization does not spend their reply deadline. Startup, color-scheme notifications, and focus-regain queries replace cached diff tints atomically. While any rendered pane shows a diff, a one-second fallback probes for silent palette changes or missed notifications; ordinary editor views do not poll. Its injected-time deadline prevents overlapping periodic requests. An unanswered/partial query expires after 250 ms and removes stale tints rather than assuming a dark theme. Valid late replies are consumed without becoming editing commands. The frontend precomputes rounded RGB blends from the original background (18% row, 38% character), propagating them to active, parked, and newly created panes.
|
||||||
|
|
||||||
`vendor/crossterm` retains Crossterm 0.29.0's upstream source and MIT license, with a small Unix decoder patch exposing `Event::ColorSchemeChanged` for `CSI ? 997 ; 1/2 n`. The report signals a palette refresh, not a choice of hard-coded light/dark colors. Valid fragmented reports and malformed color reports leave following keys intact. Cargo patches both direct and Ratatui dependency paths to this one copy; remove the patch/workspace member once upstream supports these notifications.
|
`vendor/crossterm` retains Crossterm 0.29.0's upstream source and MIT license, with a small Unix decoder patch exposing `Event::ColorSchemeChanged` for `CSI ? 997 ; 1/2 n`. The report signals a palette refresh, not a choice of hard-coded light/dark colors. Valid fragmented reports and malformed color reports leave following keys intact. Cargo patches both direct and Ratatui dependency paths to this one copy; remove the patch/workspace member once upstream supports these notifications.
|
||||||
|
|
||||||
The TUI allocates a flexible buffer viewport and optional one-row top/bottom bars, with no reserved prompt/message footer. Both bars derive visibility from configured content: empty content takes no row, and tabs-only content is hidden with a single tab in either position. Both render across ordinary, split, diff, and selector views. Ordered left/center/right groups use full-row centering, active-tab collision priority, internal padding, and independent styled edges. Floating Command/Search input owns the hardware cursor; non-focusable operation/diagnostic notifications anchor bottom-right inside the chrome-free workspace and hide obscured document carets. Higher-priority modal overlays suppress them without changing input state. Wheel hit regions clear each frame and consume reports over these popups. Zero-sized terminals render nothing; minimal-height input never reserves another row. Layout and terminal-cell text mapping remain frontend-owned.
|
The TUI allocates a flexible buffer viewport and optional one-row top/bottom bars, with no reserved prompt/message footer. Both bars derive visibility from configured content: empty content takes no row, and tabs-only content is hidden with a single tab in either position. Both render across ordinary, split, diff, and selector views. Ordered left/center/right groups use full-row centering, active-tab collision priority, internal padding, and independent styled edges; accented content and edges share the focused mode's ANSI palette colors. Floating Command/Search input owns the software cursor; non-focusable operation/diagnostic notifications anchor bottom-right inside the chrome-free workspace and hide obscured document carets. Higher-priority modal overlays suppress them without changing input state. Wheel hit regions clear each frame and consume reports over these popups. Zero-sized terminals render nothing; minimal-height input never reserves another row. Layout and terminal-cell text mapping remain frontend-owned.
|
||||||
|
|
||||||
Comparison views reserve no separate metadata header or native-buffer help footer. Revision labels render bottom-left in pane borders (combined on single-pane views), with per-side editability top-right and focus indicated by border styling. Dock and document geometry use the reclaimed workspace rows; conditional loading/error/file notices remain separate from the border metadata.
|
Comparison views reserve no separate metadata header or native-buffer help footer. Revision labels render bottom-left in pane borders (combined on single-pane views), with per-side editability top-right and focus indicated by border styling. Dock and document geometry use the reclaimed workspace rows; conditional loading/error/file notices remain separate from the border metadata.
|
||||||
|
|
||||||
|
|||||||
+12
-2
@@ -15,6 +15,8 @@ Vamp.configure do |config|
|
|||||||
config.clipboard_mode = "system"
|
config.clipboard_mode = "system"
|
||||||
config.git_blame = true # false disables current-line attribution
|
config.git_blame = true # false disables current-line attribution
|
||||||
config.git_gutter = "bar" # or "symbols", "line_numbers", "none"
|
config.git_gutter = "bar" # or "symbols", "line_numbers", "none"
|
||||||
|
config.cursor = {} # mode colors enabled; all modes use a block cursor
|
||||||
|
# config.cursor = { mode_colors: false, insert: :bar, visual: :underline }
|
||||||
config.scroll_distance = 0 # nonnegative edge distance, or nil to center
|
config.scroll_distance = 0 # nonnegative edge distance, or nil to center
|
||||||
config.scroll_step_lines = nil # Ctrl+u/d: half viewport, or a positive screen-row count
|
config.scroll_step_lines = nil # Ctrl+u/d: half viewport, or a positive screen-row count
|
||||||
config.highlight_trailing_whitespace = false # true marks line-end whitespace in red
|
config.highlight_trailing_whitespace = false # true marks line-end whitespace in red
|
||||||
@@ -58,6 +60,12 @@ Plugin paths resolve relative to the config file, not the shell's working direct
|
|||||||
`config.file_explorer_side` defaults to `"right"`; other choices are `"left"`, `"top"`, and `"bottom"`. Independent `file_explorer_view` / `file_picker_view` preferences both default to `"tree"` and accept `"list"` as an alternative. Invalid preference strings report notices and retain defaults while valid bindings still install. Non-string/empty values disable the whole configuration like other Ruby type errors.
|
`config.file_explorer_side` defaults to `"right"`; other choices are `"left"`, `"top"`, and `"bottom"`. Independent `file_explorer_view` / `file_picker_view` preferences both default to `"tree"` and accept `"list"` as an alternative. Invalid preference strings report notices and retain defaults while valid bindings still install. Non-string/empty values disable the whole configuration like other Ruby type errors.
|
||||||
`config.clipboard_mode` accepts `"system"` (default) or `"separate"`. An invalid policy string reports a notice and retains System while valid bindings still install; non-string/empty values reject the whole Ruby configuration.
|
`config.clipboard_mode` accepts `"system"` (default) or `"separate"`. An invalid policy string reports a notice and retains System while valid bindings still install; non-string/empty values reject the whole Ruby configuration.
|
||||||
|
|
||||||
|
`config.cursor` accepts a partial hash. `mode_colors` defaults to `true`: the primary software cursor and secondary text carets use the terminal's ANSI cyan in Normal, green in Insert, magenta in Visual (including Visual Line), yellow in Command, and blue in Search. These are palette entries from the current terminal color scheme, not fixed RGB colors. Set `config.cursor = { mode_colors: false }` for a neutral reverse-video block cursor (default text color for bar/underline); secondary carets retain their magenta highlight.
|
||||||
|
|
||||||
|
The `normal`, `insert`, `visual`, `command`, and `search` keys select software cursor shapes: `:block`, `:bar`, or `:underline` (strings also work). Every mode defaults to a steady block. Block and underline preserve the displayed grapheme; the bar draws `▏` over the hovered character until the cursor moves. For example, `config.cursor = { insert: :bar, visual: :underline }` changes those two shapes without disabling mode colors. Each assignment replaces the cursor configuration; omitted keys retain built-in defaults. Unknown keys, nonboolean `mode_colors`, unsupported shapes, and non-hash values reject the configuration.
|
||||||
|
|
||||||
|
Shapes apply to the primary cursor, including command/search prompts, pickers, and historical/diff text; secondary carets remain cell highlights. The cursor is painted after the focused view's overlays, takes precedence over syntax/search/selection colors, and disappears when its position is offscreen or covered by a notification. The hardware cursor stays hidden during editing; its logical position is retained for input methods, and normal exit/errors/panics restore its visibility. Vamp does not change terminal cursor color or shape settings, so multiplexers need no native cursor-color support. File contents and cursor positions remain unchanged.
|
||||||
|
|
||||||
`config.scroll_distance` defaults to `0`: keep the viewport still until its primary cursor or selected picker entry would leave it. A nonnegative integer reserves that many visible rows above/below the cursor, and terminal-cell columns left/right in unwrapped text. `nil` keeps it centered; integers at least half the current viewport dimension behave the same way until resizing makes that margin usable. The start of content limits centering, while trailing blank space allows centering near the end. Whole two-row picker entries stay aligned, using the nearest entry to the center when exact margins cannot fit.
|
`config.scroll_distance` defaults to `0`: keep the viewport still until its primary cursor or selected picker entry would leave it. A nonnegative integer reserves that many visible rows above/below the cursor, and terminal-cell columns left/right in unwrapped text. `nil` keeps it centered; integers at least half the current viewport dimension behave the same way until resizing makes that margin usable. The start of content limits centering, while trailing blank space allows centering near the end. Whole two-row picker entries stay aligned, using the nearest entry to the center when exact margins cannot fit.
|
||||||
|
|
||||||
The setting applies to ordinary and historical buffers, split/unified diff panes, file pickers and explorers, command/buffer palettes, completion lists, and revision selectors. Wrapped text measures visual rows, not source lines; picker distances use result rows, excluding borders, search fields, and help. Manual mouse-wheel scrolling remains detached until keyboard navigation resumes. Only `nil` or integers from `0` through the platform's Rust `usize` maximum (`18446744073709551615` on 64-bit systems) are accepted; other values reject the configuration.
|
The setting applies to ordinary and historical buffers, split/unified diff panes, file pickers and explorers, command/buffer palettes, completion lists, and revision selectors. Wrapped text measures visual rows, not source lines; picker distances use result rows, excluding borders, search fields, and help. Manual mouse-wheel scrolling remains detached until keyboard navigation resumes. Only `nil` or integers from `0` through the platform's Rust `usize` maximum (`18446744073709551615` on 64-bit systems) are accepted; other values reject the configuration.
|
||||||
@@ -105,7 +113,7 @@ Indicators compare the current editor text (including unsaved edits) against Git
|
|||||||
|
|
||||||
`config.diff_view_mode` selects the initial view for each new comparison: `"split"` (default) or `"unified"`. `config.diff_split_orientation` selects `"vertical"` (left/right, default) or `"horizontal"` (above/below) for split views. Invalid values reject the configuration. Normal-mode **Space v** cycles split/unified; **Space s** flips split orientation. These controls affect the current comparison, not startup defaults, and retain their choices across file switches and tabs. Orientation changes apply only in split view.
|
`config.diff_view_mode` selects the initial view for each new comparison: `"split"` (default) or `"unified"`. `config.diff_split_orientation` selects `"vertical"` (left/right, default) or `"horizontal"` (above/below) for split views. Invalid values reject the configuration. Normal-mode **Space v** cycles split/unified; **Space s** flips split orientation. These controls affect the current comparison, not startup defaults, and retain their choices across file switches and tabs. Orientation changes apply only in split view.
|
||||||
|
|
||||||
`config.diff_follow_working_copy` defaults to `true`. A Working-checkout After follows descendant checkouts automatically, including in inactive tabs; `false` disables endpoint following without disabling ordinary live-buffer editing or reload. Only literal booleans are accepted. Pinned After endpoints never move, but their checkout-aware editability checks remain enabled regardless of this setting. See [repository diff](editing.md#repository-diff) for ancestry and refresh behavior.
|
`config.diff_follow_working_copy` defaults to `true`. The After picker calls its live-files choice **Follow checkout** when enabled, otherwise **Checkout snapshot**. Following advances to descendant checkouts automatically, including in inactive tabs; `false` disables endpoint following without disabling ordinary live-buffer editing or reload. Only literal booleans are accepted. Before is always frozen. Pinned After endpoints never move, but their checkout-aware editability checks remain enabled regardless of this setting. See [repository diff](editing.md#repository-diff) for ancestry and refresh behavior.
|
||||||
|
|
||||||
`config.pull_request_review_requested_symbol` and `config.pull_request_approved_symbol` control the markers before PR numbers in the pull-request picker. Defaults are `"🙏"` for an outstanding personal review request and `"👍"` for your active approval, using the API-authenticated account. Requests take precedence over approvals when both apply. Each option accepts a string without control characters; multi-character and wide Unicode symbols are supported. Set either to `""` to hide that state, or both to `""` to remove the marker gutter. PR numbers and metadata stay aligned by terminal-cell width, including rows without a marker. Non-strings and strings containing tabs, newlines, or other control characters reject the configuration.
|
`config.pull_request_review_requested_symbol` and `config.pull_request_approved_symbol` control the markers before PR numbers in the pull-request picker. Defaults are `"🙏"` for an outstanding personal review request and `"👍"` for your active approval, using the API-authenticated account. Requests take precedence over approvals when both apply. Each option accepts a string without control characters; multi-character and wide Unicode symbols are supported. Set either to `""` to hide that state, or both to `""` to remove the marker gutter. PR numbers and metadata stay aligned by terminal-cell width, including rows without a marker. Non-strings and strings containing tabs, newlines, or other control characters reject the configuration.
|
||||||
|
|
||||||
@@ -135,6 +143,8 @@ config.bind "leader s", config.core.diff.orientation.toggle
|
|||||||
|
|
||||||
`config.bar.top` and `config.bar.bottom` each accept ordered `left`, `center`, and `right` arrays of segment names. Both bars accept every builtin and custom segment, with the same anchors, title templates, margins, padding, styled edges, and collision handling. Left and right groups anchor to their respective edges; the center group is centered on the whole row, not on the gap remaining between the other groups. Array order is display order within each group. Omitted groups and segment options retain that bar's defaults; an empty array removes that group.
|
`config.bar.top` and `config.bar.bottom` each accept ordered `left`, `center`, and `right` arrays of segment names. Both bars accept every builtin and custom segment, with the same anchors, title templates, margins, padding, styled edges, and collision handling. Left and right groups anchor to their respective edges; the center group is centered on the whole row, not on the gap remaining between the other groups. Array order is display order within each group. Omitted groups and segment options retain that bar's defaults; an empty array removes that group.
|
||||||
|
|
||||||
|
Accented segments (including active tabs, mode, position, and custom labels), their padding, and decorative edge colors follow the focused editing mode using the same ANSI palette as the software cursor. Both bars change together; inactive tabs, neutral text, separators, and margins retain their existing styling. Setting `config.cursor = { mode_colors: false }` affects cursors only, not the bars.
|
||||||
|
|
||||||
The renderer inserts one unstyled blank column between visible items in each group, including tab titles. Hidden items contribute no separator; there is no separator before the first or after the last item. Separator widths count toward anchoring and collision handling. Plain-text segments default to no padding or margins, so adjacent labels do not double their spacing.
|
The renderer inserts one unstyled blank column between visible items in each group, including tab titles. Hidden items contribute no separator; there is no separator before the first or after the last item. Separator widths count toward anchoring and collision handling. Plain-text segments default to no padding or margins, so adjacent labels do not double their spacing.
|
||||||
|
|
||||||
Use the two setters independently; a combined `config.bar = { ... }` assignment is not supported.
|
Use the two setters independently; a combined `config.bar = { ... }` assignment is not supported.
|
||||||
@@ -342,7 +352,7 @@ config.bind "ctrl+h", nil, mode: config.modes.tree
|
|||||||
|
|
||||||
The diff's changed-file explorer and floating picker use these same tree and selection contexts, including remaps and disabled keys. Enter opens the selected file's comparison; writable After reuses its live buffer. The catalog contains only changed paths, with A/M/D/R status markers, rather than a disk scan.
|
The diff's changed-file explorer and floating picker use these same tree and selection contexts, including remaps and disabled keys. Enter opens the selected file's comparison; writable After reuses its live buffer. The catalog contains only changed paths, with A/M/D/R status markers, rather than a disk scan.
|
||||||
|
|
||||||
Ordinary `config.core.file_picker` and `config.core.file_explorer` bindings work inside comparisons. Ctrl+w closes a focused picker/explorer, retaining its query, selection, and expansion state; the panes use the reclaimed width. Ctrl+e has no reserved diff behavior. Escape closes the revision selector in either stage, but an opened comparison still closes explicitly through `:diff.close`, its palette action, or the generated action value below. `config.core.diff` remains the opening action and exposes immutable `close`, `mode.toggle`, `view.toggle`, and `orientation.toggle` children. New comparisons default to Branch changes (merge base → selected After); Normal-mode Ctrl+b, `:diff.mode.toggle`, or the palette toggles to direct Snapshots and back, preserving the selected endpoints. Selecting Working checkout on either side forces Snapshots.
|
Ordinary `config.core.file_picker` and `config.core.file_explorer` bindings work inside comparisons. Ctrl+w closes a focused picker/explorer, retaining its query, selection, and expansion state; the panes use the reclaimed width. Ctrl+e has no reserved diff behavior. Escape closes the revision selector in either stage, but an opened comparison still closes explicitly through `:diff.close`, its palette action, or the generated action value below. `config.core.diff` remains the opening action and exposes immutable `close`, `mode.toggle`, `view.toggle`, and `orientation.toggle` children. New comparisons default to Branch changes (merge base → selected After); Normal-mode Ctrl+b, `:diff.mode.toggle`, or the palette toggles to direct Snapshots and back, preserving the selected endpoints. Selecting Checkout snapshot or Follow checkout forces Snapshots.
|
||||||
|
|
||||||
`config.core.diff.review.toggle` is a non-mutating Normal-mode action, also available as `:diff.review.toggle` and in the palette. It marks/unmarks the highlighted changed-file leaf when the comparison's explorer is focused, or the current comparison file from a text pane. Read-only snapshots permit review marks. The default `c` shortcut applies only while browsing the diff's changed-file tree; explicit bindings and disabled keys override it. For example, `config.bind "ctrl+g", config.core.diff.review.toggle`; use the palette or a control-key binding while filtering, including List view, so printable input stays in the query.
|
`config.core.diff.review.toggle` is a non-mutating Normal-mode action, also available as `:diff.review.toggle` and in the palette. It marks/unmarks the highlighted changed-file leaf when the comparison's explorer is focused, or the current comparison file from a text pane. Read-only snapshots permit review marks. The default `c` shortcut applies only while browsing the diff's changed-file tree; explicit bindings and disabled keys override it. For example, `config.bind "ctrl+g", config.core.diff.review.toggle`; use the palette or a control-key binding while filtering, including List view, so printable input stays in the query.
|
||||||
|
|
||||||
|
|||||||
+9
-4
@@ -79,7 +79,8 @@ The two skipped shell cleanup checks execute `/bin/ps`, which macOS refuses to l
|
|||||||
- Battery checks inject samples and refresh gates, use temporary Linux sysfs fixtures, and validate native value types/bounds without depending on the machine's charge or clock. Unit sessions do not automatically read hardware. Live smoke should compare the default indicator with OS telemetry and observe a production refresh; no-battery, explicit overrides, and disabled anchors must not fabricate values.
|
- Battery checks inject samples and refresh gates, use temporary Linux sysfs fixtures, and validate native value types/bounds without depending on the machine's charge or clock. Unit sessions do not automatically read hardware. Live smoke should compare the default indicator with OS telemetry and observe a production refresh; no-battery, explicit overrides, and disabled anchors must not fabricate values.
|
||||||
- Status redesign checks cover component-aware home abbreviation without save-path changes; floating command/search submit/cancel, Unicode/tiny/offset clipping and wheel isolation; footer-row reclamation; notification Escape/modal/prefix priority and covered-caret visibility; and ready-server metadata using real framed subprocess fixtures, including stacks, initialization gates, failure, and focus changes.
|
- Status redesign checks cover component-aware home abbreviation without save-path changes; floating command/search submit/cancel, Unicode/tiny/offset clipping and wheel isolation; footer-row reclamation; notification Escape/modal/prefix priority and covered-caret visibility; and ready-server metadata using real framed subprocess fixtures, including stacks, initialization gates, failure, and focus changes.
|
||||||
- Unix terminal-hook checks use isolated child processes and real PTYs to keep process-global hooks out of the parallel runner. They cover raw/alternate-screen/mouse-capture cleanup on entry failure, normal return, I/O errors, and panics during frame construction, including original panic-payload propagation.
|
- Unix terminal-hook checks use isolated child processes and real PTYs to keep process-global hooks out of the parallel runner. They cover raw/alternate-screen/mouse-capture cleanup on entry failure, normal return, I/O errors, and panics during frame construction, including original panic-payload propagation.
|
||||||
- Redraw checks inspect actual Crossterm output during repeated scrolling: supporting terminals receive synchronized frames, terminals ignoring synchronization never paint with a visible hardware cursor, and the final caret remains visible. A protocol-state regression verifies that the terminal is not left in synchronized-output mode while the next frame is computed.
|
- Redraw checks inspect actual Crossterm output during repeated scrolling: supporting terminals receive synchronized frames, terminals ignoring synchronization never paint with a visible hardware cursor, and the final software caret remains visible. A protocol-state regression verifies that the terminal is not left in synchronized-output mode while the next frame is computed.
|
||||||
|
- Cursor checks render software block/bar/underline shapes, wide/combining graphemes, cell restoration after motion, neutral reverse-video blocks, and shared ANSI mode accents on both banners through Normal, Insert, Visual, Command, and Search. The production backend preserves logical cursor coordinates without showing the hardware cursor. Live PTY screen parsing and an isolated Herdr 0.9.5 full-client smoke verify that mode-colored cells survive multiplexing, match both banners, avoid OSC 12, leave source bytes unchanged, and restore hardware visibility on exit. Document-layer fixtures exclude the final cursor overlay; complete-frame checks use `BufferViewports` with the production backend adapter.
|
||||||
- Wheel checks render document/panel views and cover three-row scrolling, unchanged carets/selections/text, first/last-page clamping, offscreen cursor hiding, no-op/custom-bound navigation reveal, manual persistence through idle/resize/palette updates, and floating/unfocused-docked explorer selection and focus preservation. Queued-input checks verify that the next frame shows ordered wheel steps without crossing a key or resize boundary.
|
- Wheel checks render document/panel views and cover three-row scrolling, unchanged carets/selections/text, first/last-page clamping, offscreen cursor hiding, no-op/custom-bound navigation reveal, manual persistence through idle/resize/palette updates, and floating/unfocused-docked explorer selection and focus preservation. Queued-input checks verify that the next frame shows ordered wheel steps without crossing a key or resize boundary.
|
||||||
- Queued-navigation checks cover counted ordered motions through short rows, Normal/Visual ranges, remapped press/repeat keys, rendered caret visibility, edit/layout/prefix and non-key barriers, and the 64-key fairness bound. Burst smoke must verify the final cursor position as well as frame count; batching reduces queued redraws without increasing the OS/terminal repeat rate.
|
- Queued-navigation checks cover counted ordered motions through short rows, Normal/Visual ranges, remapped press/repeat keys, rendered caret visibility, edit/layout/prefix and non-key barriers, and the 64-key fairness bound. Burst smoke must verify the final cursor position as well as frame count; batching reduces queued redraws without increasing the OS/terminal repeat rate.
|
||||||
- Native LSP integration checks launch framed-protocol fixture subprocesses, covering initialization/settings, opt-in startup, synchronization, UTF-16/file-URI conversion, dirty targets, stale requests/diagnostics, and transport failures. Stack checks cover independent initialization, diagnostic merging/clearing, sibling failure isolation in active/parked buffers, same-revision rename/close identity, aggregate limits, first-server position routing, and both shutdowns. Semantic ordering uses protocol/file gates; elapsed deadlines only bound stuck subprocesses.
|
- Native LSP integration checks launch framed-protocol fixture subprocesses, covering initialization/settings, opt-in startup, synchronization, UTF-16/file-URI conversion, dirty targets, stale requests/diagnostics, and transport failures. Stack checks cover independent initialization, diagnostic merging/clearing, sibling failure isolation in active/parked buffers, same-revision rename/close identity, aggregate limits, first-server position routing, and both shutdowns. Semantic ordering uses protocol/file gates; elapsed deadlines only bound stuck subprocesses.
|
||||||
@@ -98,11 +99,13 @@ Diff-stat checks cover unopened frozen files, additions/deletions/renames, binar
|
|||||||
|
|
||||||
Local review checks cover native comment/summary composition and undo, Visual ranges on either side, verdicts, JSON save/reopen, external-write refusal, hidden drafts and teardown guards, immutable Working identities in Git/jj, renamed paths, live EOF, and stale anchors. Render checks cover selected/range markers, split/unified context, wrapped continuations, and narrow gutters. Live PTY smoke creates and saves a comment/summary/verdict, closes and reopens the review, edits the reopened comment, changes the reviewed source, and observes stale status without altering the saved anchor. Fixtures are local repositories; no forge writes.
|
Local review checks cover native comment/summary composition and undo, Visual ranges on either side, verdicts, JSON save/reopen, external-write refusal, hidden drafts and teardown guards, immutable Working identities in Git/jj, renamed paths, live EOF, and stale anchors. Render checks cover selected/range markers, split/unified context, wrapped continuations, and narrow gutters. Live PTY smoke creates and saves a comment/summary/verdict, closes and reopens the review, edits the reopened comment, changes the reviewed source, and observes stale status without altering the saved anchor. Fixtures are local repositories; no forge writes.
|
||||||
|
|
||||||
Ordinary-source review checks cover unsaved Unicode selections, line/empty-line anchors, no storage creation on initialization, explicit save/discard, optimistic conflicts, tab/pane guards, JSON round-trips, stale read-only snapshots, and rendered exact-range markers. Live PTY smoke selects a source line, creates a comment with `:comment`, saves with `:w`, verifies its exact excerpt through the review CLI, restarts with `:review.next`, and reopens after changing the selected source to observe the preserved original snapshot. Review state is tab-owned rather than comparison-owned; diff regression coverage remains required.
|
Ordinary-source review checks cover unsaved Unicode selections, line/empty-line anchors, no storage creation on initialization, explicit save/discard, optimistic conflicts, tab/pane guards, JSON round-trips, stale read-only snapshots, and rendered exact-range markers. Review selection separates source and comparison scopes even within one tab; dirty drafts guard transitions, and mismatched stored/JSON opens cannot reassign feedback. Review state remains tab-owned, with the selected document scoped to its source or comparison.
|
||||||
|
|
||||||
Review clipboard checks cover System/Separate routing with isolated providers, exact Unicode excerpts, resolved/stale comments across files, retained unsaved drafts, unchanged storage revisions, and empty/invalid/failed-copy preservation. Live PTY smoke imports a review, copies it, pastes into an empty buffer, and verifies the saved bytes exactly match CLI `show --all`; default CLI output still excludes resolved comments.
|
Review clipboard checks cover System/Separate routing with isolated providers, exact Unicode excerpts, resolved/stale comments across files, retained unsaved drafts, unchanged storage revisions, and empty/invalid/failed-copy preservation. Live PTY smoke imports a review, copies it, pastes into an empty buffer, and verifies the saved bytes exactly match CLI `show --all`; default CLI output still excludes resolved comments.
|
||||||
|
|
||||||
SQLite review checks cover exact anchors across restarts and revision changes, repository isolation, independent connections and optimistic conflicts, locked/unwritable stores, incompatible schema preservation, relational constraints, full unsigned comment IDs, stable comment order, bounded/corrupt rows, rollback after partial comment insertion, private file permissions, and XDG/config path resolution. Schema 1 document storage is rejected without modification. Editor checks require explicit writes for comment/summary text, discard unsaved text on dismissal without retaining discarded undo history, prevent metadata autosaves from committing hidden drafts, persist verdicts/deletions, restore a sole review, choose among multiple reviews, and keep exports independent from autosaves. Test fixtures use isolated review directories. Live PTY smoke verifies that Escape leaves no new stored comment, `:w` persists exact text, and pane close discards a later edit; reopening and writing again preserves the saved body.
|
Review text-format checks cover language-tagged excerpts, Honey's `hon` fence alias, special filenames and frozen shebang detection, unknown languages, Unicode, trailing-newline preservation, and nested backtick fences. Live PTY/CLI smoke imports `.hon` and nested Markdown comments, copies/pastes them into a saved buffer, compares its exact bytes with `show --all`, and verifies unresolved filtering, raw JSON excerpts, unchanged source bytes and an unchanged stored review revision.
|
||||||
|
|
||||||
|
SQLite review checks cover exact anchors, ordered comparison-scope round-trips and immutable scope saves, repository isolation, independent connections and optimistic conflicts, locked/unwritable stores, incompatible schema preservation, relational constraints, full unsigned comment IDs, stable comment order, bounded/corrupt rows, rollback after partial comment insertion, private file permissions, and XDG/config path resolution. SQLite schemas through 3 and JSON versions through 4 are rejected without modification. Editor checks cover automatic selection for different endpoint pairs, restoration of the same pair, stable in-place Working refreshes, moved-bookmark scope changes, separate source feedback, per-scope selectors, and explicit draft-save/discard behavior. Live PTY/CLI smoke saves feedback on two immutable pairs, reopens the first to restore only its feedback, retains Working feedback through refresh, saves a separate source comment, and inspects all scopes through SQLite and CLI JSON. Fixtures use isolated repositories and review directories; no forge writes.
|
||||||
|
|
||||||
Manual-resolution checks cover immediate persistence, reopen/export/import, independent stale status, conflict-retained drafts, and incompatible version preservation. CLI checks cover filename-safe dispatch, Unicode excerpts, unresolved filtering/counts, absent storage, read-only access and permissions, symlink rejection, and incompatible schema preservation.
|
Manual-resolution checks cover immediate persistence, reopen/export/import, independent stale status, conflict-retained drafts, and incompatible version preservation. CLI checks cover filename-safe dispatch, Unicode excerpts, unresolved filtering/counts, absent storage, read-only access and permissions, symlink rejection, and incompatible schema preservation.
|
||||||
Live PTY/CLI smoke composes and explicitly saves a comment, resolves it in the editor, observes an empty default CLI comment list with explicit counts, retrieves the resolved comment and exact excerpt with `--all`, and unresolves it. Each transition advances the same stored review revision; listing absent storage creates no data directory.
|
Live PTY/CLI smoke composes and explicitly saves a comment, resolves it in the editor, observes an empty default CLI comment list with explicit counts, retrieves the resolved comment and exact excerpt with `--all`, and unresolves it. Each transition advances the same stored review revision; listing absent storage creates no data directory.
|
||||||
@@ -117,9 +120,11 @@ Conflicted-revision regressions preserve unchanged symlinks, defer oversized uns
|
|||||||
|
|
||||||
Resolver checks use isolated real jj repositories: read-only discovery, marker-free all-side/base projections preserving outside edits and EOF, initial seed undo, hunk choice/undo, retained resolved files, jj-authoritative status, checkout/external-write refusal, cancellation, nontext bytes/types/modes/deletion, and directory-child protection. Render checks cover aligned gaps/highlights, context folding, native Draft focus/search/selection, separate History, hidden/on-demand explorer, viewport retention and narrow layouts. Live PTY smoke exercises frozen marker-free alternatives, History/Draft switching, hunk choice and guarded save confirmation without touching user repositories.
|
Resolver checks use isolated real jj repositories: read-only discovery, marker-free all-side/base projections preserving outside edits and EOF, initial seed undo, hunk choice/undo, retained resolved files, jj-authoritative status, checkout/external-write refusal, cancellation, nontext bytes/types/modes/deletion, and directory-child protection. Render checks cover aligned gaps/highlights, context folding, native Draft focus/search/selection, separate History, hidden/on-demand explorer, viewport retention and narrow layouts. Live PTY smoke exercises frozen marker-free alternatives, History/Draft switching, hunk choice and guarded save confirmation without touching user repositories.
|
||||||
|
|
||||||
|
Base-relative resolver regressions cover red display-only removals and green native additions on both sides, deletion/replacement gaps, add/add without a base, native-coordinate round trips and EOF rows. The rendered workspace check exercises both split orientations and unified mode, native edits, hunk choice/undo, and both History layouts. Live PTY smoke checks actual red `-` and green `+` cells on both candidates, opens columns/base-below History, verifies browsing leaves conflict bytes untouched, and takes/saves an Alternative with exact output bytes and jj confirmation.
|
||||||
|
|
||||||
Conflict History layout checks load `resolve_history_layout` through the real Ruby configuration and render both left/base/right columns and left/right over base. They cover source contents, full-width base geometry, Tab/directional focus, synchronized wheel scrolling, read-only base protection, narrow/short fallbacks and preserved Draft text. Live PTY smoke opens both layouts, focuses/scrolls the base, returns to Resolution, takes a hunk and saves; jj confirms the checkout is no longer conflicted.
|
Conflict History layout checks load `resolve_history_layout` through the real Ruby configuration and render both left/base/right columns and left/right over base. They cover source contents, full-width base geometry, Tab/directional focus, synchronized wheel scrolling, read-only base protection, narrow/short fallbacks and preserved Draft text. Live PTY smoke opens both layouts, focuses/scrolls the base, returns to Resolution, takes a hunk and saves; jj confirms the checkout is no longer conflicted.
|
||||||
|
|
||||||
Shared-workspace parity checks compare ordinary and resolver diff geometry/content under identical chrome, focus and source cursors. Cover native picker selection/remaps, Tree/List and dock/floating actions, excerpt initialization, folded search, focus transfer on wheel, layout controls, loading/History/pane-rebuild transitions, and preserved per-file viewport state. Live PTY smoke uses Space e, Down/Enter file selection and guarded saving through the same explorer path.
|
Shared-workspace checks exercise ordinary and resolver diffs with native chrome, focus and source cursors. Cover native picker selection/remaps, Tree/List and dock/floating actions, excerpt initialization, folded search, focus transfer on wheel, layout controls, loading/History/pane-rebuild transitions, and preserved per-file viewport state. Live PTY smoke uses Space e, Down/Enter file selection and guarded saving through the same explorer path.
|
||||||
|
|
||||||
Repository gutter checks cover LF/CRLF normalization, additions/replacements/deletion anchors, staged/disk/unsaved Git text, unborn and missing named files, ignored/unsupported paths, read-only colocated/non-colocated jj queries, stale results and per-buffer isolation, plus rendered modes, colors, current-line precedence, wrap/scroll and narrow/split geometry. Live PTY smoke verifies all four modes, edit/undo refresh, external Git HEAD changes, and jj parent changes without editor restart.
|
Repository gutter checks cover LF/CRLF normalization, additions/replacements/deletion anchors, staged/disk/unsaved Git text, unborn and missing named files, ignored/unsupported paths, read-only colocated/non-colocated jj queries, stale results and per-buffer isolation, plus rendered modes, colors, current-line precedence, wrap/scroll and narrow/split geometry. Live PTY smoke verifies all four modes, edit/undo refresh, external Git HEAD changes, and jj parent changes without editor restart.
|
||||||
|
|
||||||
|
|||||||
+25
-13
@@ -74,7 +74,7 @@ With an enabled server that provides completion, Ctrl+n in Insert requests candi
|
|||||||
|
|
||||||
Every candidate is applied from that same immutable original document, not on top of the previous candidate. A candidate's primary replacement and additional text edits are one atomic change. Other keys close the list without accepting it: plain-text completion retains ordinary typing, newline, and configured Tab insertion; snippet completion keeps its argument navigation described below. Escape returns to Normal retaining the displayed text. There is no automatic popup. Empty, unsupported, or invalid results leave the document unchanged and report a message.
|
Every candidate is applied from that same immutable original document, not on top of the previous candidate. A candidate's primary replacement and additional text edits are one atomic change. Other keys close the list without accepting it: plain-text completion retains ordinary typing, newline, and configured Tab insertion; snippet completion keeps its argument navigation described below. Escape returns to Normal retaining the displayed text. There is no automatic popup. Empty, unsupported, or invalid results leave the document unchanged and report a message.
|
||||||
|
|
||||||
Cycling creates no separate undo entries. Only the final chosen edits join the existing Insert undo group; choosing Original text adds no completion edit. Undo restores that group, and dot repeats the final resolved completion edits rather than requests or intermediate candidates. The list stays inside the document text workspace, does not cover the gutter or explorer, and never takes the hardware cursor. Wheel input over it is consumed without choosing a candidate.
|
Cycling creates no separate undo entries. Only the final chosen edits join the existing Insert undo group; choosing Original text adds no completion edit. Undo restores that group, and dot repeats the final resolved completion edits rather than requests or intermediate candidates. The list stays inside the document text workspace, does not cover the gutter or explorer, and never takes the primary cursor. Wheel input over it is consumed without choosing a candidate.
|
||||||
|
|
||||||
Snippet candidates expand the full template immediately, including parentheses and argument defaults. The first numbered argument is highlighted; typing replaces it, then continues within that argument. Repeated occurrences of the same argument update together, without broadcasting to secondary carets. Backspace/Delete stay within the active argument; Enter can insert a newline there. **Tab** moves forward and **Shift+Tab** backward in ascending tabstop order. Replacing an enclosing argument removes its nested arguments, not adjacent independent ones. Reaching the final `$0` position ends navigation, so subsequent Tab uses the configured spaces-or-tab insertion. Escape, ordinary cursor motion, paste, commands, and focus/buffer changes also end navigation without removing the expanded text.
|
Snippet candidates expand the full template immediately, including parentheses and argument defaults. The first numbered argument is highlighted; typing replaces it, then continues within that argument. Repeated occurrences of the same argument update together, without broadcasting to secondary carets. Backspace/Delete stay within the active argument; Enter can insert a newline there. **Tab** moves forward and **Shift+Tab** backward in ascending tabstop order. Replacing an enclosing argument removes its nested arguments, not adjacent independent ones. Reaching the final `$0` position ends navigation, so subsequent Tab uses the configured spaces-or-tab insertion. Escape, ordinary cursor motion, paste, commands, and focus/buffer changes also end navigation without removing the expanded text.
|
||||||
|
|
||||||
@@ -126,7 +126,7 @@ System clipboard publication must succeed before any deletion. Failure preserves
|
|||||||
|
|
||||||
## Selections and multi-caret editing
|
## Selections and multi-caret editing
|
||||||
|
|
||||||
Editing uses an ordered selection set with one primary target. Cursor and word motions apply to every target; Visual motions keep each target's anchor and move its active endpoint. Whole combining sequences and ZWJ emoji remain intact. `v` selects characterwise, inclusive of endpoints; `V` selects every complete row touched by the endpoints, including blank and empty final rows. Switching between `v` and `V` retains endpoints; pressing the current shape's selector again cancels. Read-only ranges are end-exclusive. Characterwise `$`/End can select the newline after a non-final row; selected newlines appear as a blue blank cell after the line. Linewise highlighting covers the copied text, including trailing whitespace, plus one blank cell for each copied newline; unused viewport space and the gutter remain unhighlighted. The status reads VISUAL LINE. Blue highlighting overrides yellow search highlighting; secondary carets are magenta, including on empty lines and at line ends. Only the primary owns the hardware cursor and viewport. The status line counts all targets, including offscreen targets.
|
Editing uses an ordered selection set with one primary target. Cursor and word motions apply to every target; Visual motions keep each target's anchor and move its active endpoint. Whole combining sequences and ZWJ emoji remain intact. `v` selects characterwise, inclusive of endpoints; `V` selects every complete row touched by the endpoints, including blank and empty final rows. Switching between `v` and `V` retains endpoints; pressing the current shape's selector again cancels. Read-only ranges are end-exclusive. Characterwise `$`/End can select the newline after a non-final row; selected newlines appear as a blue blank cell after the line. Linewise highlighting covers the copied text, including trailing whitespace, plus one blank cell for each copied newline; unused viewport space and the gutter remain unhighlighted. The status reads VISUAL LINE. Blue highlighting overrides yellow search highlighting; secondary carets follow the mode palette (magenta in Visual), including on empty lines and at line ends. The primary software cursor overlays these text colors and alone drives the viewport; its shape and mode colors are [configurable](configuration.md#preferences). The status line counts all targets, including offscreen targets.
|
||||||
|
|
||||||
Ctrl+k/Ctrl+e clone each target to the adjacent row, preserving preferred terminal-cell columns (tabs use four-cell stops); Visual cloning preserves both endpoints. Duplicate carets and truly overlapping selections merge; adjacent ranges remain distinct. Visual Ctrl+l creates one Normal caret at the first nonblank of each touched row. Visual `I`/`A` similarly create one Insert caret per touched row at first nonblank/end. Occurrence actions use the primary selection as a literal, potentially multiline, grapheme-aligned query: Ctrl+n adds the next non-overlapping occurrence and wraps; Ctrl+s moves only the primary to that occurrence; Ctrl+a selects all occurrences.
|
Ctrl+k/Ctrl+e clone each target to the adjacent row, preserving preferred terminal-cell columns (tabs use four-cell stops); Visual cloning preserves both endpoints. Duplicate carets and truly overlapping selections merge; adjacent ranges remain distinct. Visual Ctrl+l creates one Normal caret at the first nonblank of each touched row. Visual `I`/`A` similarly create one Insert caret per touched row at first nonblank/end. Occurrence actions use the primary selection as a literal, potentially multiline, grapheme-aligned query: Ctrl+n adds the next non-overlapping occurrence and wraps; Ctrl+s moves only the primary to that occurrence; Ctrl+a selects all occurrences.
|
||||||
|
|
||||||
@@ -390,16 +390,20 @@ The project root is the working directory captured at startup. Scanning uses the
|
|||||||
|
|
||||||
Run `:diff` in Normal mode, choose **Before**, then **After**, and press Enter to compare. The command palette also exposes **Diff**; there is no default keybinding. Repository discovery starts at the active file's directory, or the startup project root for an unnamed buffer, and prefers jj over Git.
|
Run `:diff` in Normal mode, choose **Before**, then **After**, and press Enter to compare. The command palette also exposes **Diff**; there is no default keybinding. Repository discovery starts at the active file's directory, or the startup project root for an unnamed buffer, and prefers jj over Git.
|
||||||
|
|
||||||
The floating selector has **Revisions / Bookmarks** tabs for jj, or **Commits / Branches / Tags** for Git. Both Before and After support case-insensitive fuzzy search by revision/change ID, commit ID, reference name, or description: `f1205pas` can find `sm/fin-1205-pilot-assignment-schema`. Space-separated tokens must all match, but can match different fields in any order; a single token cannot span unrelated fields. Exact, prefix, and contiguous matches rank ahead of scattered subsequences; otherwise equal matches favor names/IDs over descriptions, word boundaries, and tighter spans. Ties retain catalogue order, and an empty or whitespace-only query restores the original order. **Working checkout** stays first in every category, even when it does not match the query. As Before it is a disk snapshot, including eligible untracked files. As After it includes unsaved regular-text buffers and enables live editing; opening a comparison never saves them.
|
The floating selector has **Revisions / Bookmarks** tabs for jj, or **Commits / Branches / Tags** for Git. Both jj tabs use flat lists without ancestry lanes. **Revisions** puts the short change ID and commit description on the first line; bookmarks, author, date, commit ID, and textual **[checkout]** / **[immutable]** badges appear on a dimmed second line. **Bookmarks** puts the name first, with the commit description underneath. Local and remote-tracking bookmarks remain individually selectable, including multiple names on one revision, preserving their symbolic follow behavior. Narrow windows omit revision metadata and use compact single-line entries.
|
||||||
|
|
||||||
New comparisons default to **Branch changes**: Before becomes the unique merge base of the selected Before and After revisions, and After stays the selected tip. Changes exclusive to the Before branch are excluded. The Before border identifies both selected names and the immutable merge-base commit ID. **Snapshots** compares the two selected snapshots directly. **Ctrl+b** toggles the mode, also available as **`:diff.mode.toggle`**, the palette action, and remappable **`config.core.diff.mode.toggle`**. Toggling an opened comparison recomputes using the original selected endpoints. Selecting **Working checkout** on either side immediately forces **Snapshots**; Branch changes is unavailable while it is selected. Missing or multiple merge bases produce an explicit, recoverable selector error rather than silently falling back; choose Snapshots to compare such histories. jj's synthetic root is the empty ancestor of every jj history.
|
Both stages support case-insensitive fuzzy search by revision/change ID, commit ID, reference name, or description: `f1205pas` can find `sm/fin-1205-pilot-assignment-schema`. Space-separated tokens must all match, but can match different fields; a token cannot span unrelated fields. jj keeps catalogue order while filtering. Git ranks exact, prefix, and contiguous matches ahead of scattered subsequences, with names/IDs ahead of descriptions and catalogue order breaking ties. An empty query restores catalogue order. After choosing Before, its revision carries a **[Before]** badge instead of a separate summary row. It remains included through search and category changes, but scrolls normally.
|
||||||
|
|
||||||
|
The separate checkout choice stays first in every category regardless of the query. It is **Checkout snapshot** during Before selection: a frozen disk snapshot including eligible untracked files. During After selection it is **Follow checkout** when `config.diff_follow_working_copy` is enabled, otherwise **Checkout snapshot**. Either After choice includes unsaved regular-text buffers and enables live editing; opening a comparison never saves them. Unlike this live-files choice, selecting the recorded checkout revision in Revisions pins that revision.
|
||||||
|
|
||||||
|
New comparisons default to **Branch changes**: Before becomes the unique merge base of the selected Before and After revisions, and After stays the selected tip. Changes exclusive to the Before branch are excluded. The Before border identifies both selected names and the immutable merge-base commit ID. **Snapshots** compares the two selected snapshots directly. **Ctrl+b** toggles the mode, also available as **`:diff.mode.toggle`**, the palette action, and remappable **`config.core.diff.mode.toggle`**. Toggling an opened comparison recomputes using the original selected endpoints. Selecting **Checkout snapshot** or **Follow checkout** immediately forces **Snapshots**; Branch changes is unavailable while either is selected. Missing or multiple merge bases produce an explicit, recoverable selector error rather than silently falling back; choose Snapshots to compare such histories. jj's synthetic root is the empty ancestor of every jj history.
|
||||||
|
|
||||||
| Selector input | Effect |
|
| Selector input | Effect |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Tab / Shift+Tab | Next / previous category |
|
| Tab / Shift+Tab | Next / previous category |
|
||||||
| Up / Down, Ctrl+k / Ctrl+j | Previous / next entry |
|
| Up / Down, Ctrl+k / Ctrl+j | Previous / next entry |
|
||||||
| Backspace / Ctrl+u | Remove a query grapheme / clear query |
|
| Backspace / Ctrl+u | Remove a query grapheme / clear query |
|
||||||
| Ctrl+b | Toggle Branch changes / Snapshots (Working checkout requires Snapshots) |
|
| Ctrl+b | Toggle Branch changes / Snapshots (checkout choices require Snapshots) |
|
||||||
| Enter | Choose the entry |
|
| Enter | Choose the entry |
|
||||||
| Esc | Close either selector stage, cancel outstanding work, and restore the source buffer without discarding edits |
|
| Esc | Close either selector stage, cancel outstanding work, and restore the source buffer without discarding edits |
|
||||||
|
|
||||||
@@ -443,9 +447,9 @@ Comparison discovery builds the changed-path/status catalog first. File text, no
|
|||||||
|
|
||||||
Conflicted jj revisions also discover files without reading every blob. Ordinary files and symbolic links retain their Git contents; selected conflicted files show jj's rendered conflict projection. Unchanged links and jj's internal merge-tree metadata do not appear as changes. Viewing a conflict does not resolve it or modify the revision.
|
Conflicted jj revisions also discover files without reading every blob. Ordinary files and symbolic links retain their Git contents; selected conflicted files show jj's rendered conflict projection. Unchanged links and jj's internal merge-tree metadata do not appear as changes. Viewing a conflict does not resolve it or modify the revision.
|
||||||
|
|
||||||
**Working checkout After follows forward checkouts automatically**, checked asynchronously once per second, including inactive tabs. A descendant jj change or Git HEAD rebuilds the changed-file catalog and captures current working bytes plus eligible unsaved buffers, while keeping the original Before frozen. jj tracking uses change IDs, so snapshot/description rewrites of the same change do not advance the endpoint or prevent following its later descendants. Same, backward, and unrelated checkouts do not rebuild the catalog; live-buffer editing and external reload still work normally. Historical After never follows. Set `config.diff_follow_working_copy = false` to disable following.
|
**Follow checkout After follows descendant checkouts automatically**, checked asynchronously once per second, including inactive tabs. A descendant jj change or Git HEAD rebuilds the changed-file catalog and captures current working bytes plus eligible unsaved buffers, while keeping the original Before frozen. jj tracking uses change IDs, so snapshot/description rewrites of the same change do not advance the endpoint or prevent following its later descendants. Same, backward, and unrelated checkouts do not rebuild the catalog; live-buffer editing and external reload still work normally. Historical After never follows. Set `config.diff_follow_working_copy = false` to disable following; the picker then calls this choice **Checkout snapshot**.
|
||||||
|
|
||||||
**Selected jj bookmarks follow their locally known targets automatically**, checked on the same one-second interval, including inactive tabs and with `config.diff_follow_working_copy = false`. Local bookmarks and known remote-tracking bookmarks retain their names; explicit revision selections remain pinned. A changed commit ID, including an amendment of the same change ID, rebuilds the comparison. Branch changes also recomputes the merge base. Unrelated endpoints stay frozen, except for the existing Working After forward-follow behavior. Deleted or conflicted bookmarks keep the last successful comparison visible with an error; a later successful check clears the error. These checks never fetch, contact remotes, or snapshot jj's working copy; privately loaded remote history and advertised remote selections remain frozen.
|
**Selected jj bookmarks follow their locally known targets automatically**, checked on the same one-second interval, including inactive tabs and with `config.diff_follow_working_copy = false`. Local bookmarks and known remote-tracking bookmarks retain their names; explicit revision selections remain pinned. A changed commit ID, including an amendment of the same change ID, rebuilds the comparison. Branch changes also recomputes the merge base. Unrelated endpoints stay frozen, except for the existing Follow checkout After behavior. Deleted or conflicted bookmarks keep the last successful comparison visible with an error; a later successful check clears the error. These checks never fetch, contact remotes, or snapshot jj's working copy; privately loaded remote history and advertised remote selections remain frozen.
|
||||||
|
|
||||||
Deleted or conflicted bookmarks unrelated to the selected endpoints do not block opening or refreshing a comparison.
|
Deleted or conflicted bookmarks unrelated to the selected endpoints do not block opening or refreshing a comparison.
|
||||||
|
|
||||||
@@ -453,7 +457,7 @@ Deleted or conflicted bookmarks unrelated to the selected endpoints do not block
|
|||||||
|
|
||||||
Refresh retains the selected path when still present, explorer query/view/visibility, logical text-side focus, and split/unified/orientation/wrap choices; moving a Working or bookmark endpoint clears review marks, while pinned checkout-only transitions retain them. Removed selections fall back to the first changed file. Bookmark replacements preserve/clamp cursor and scroll positions as replacement content loads. Failed refreshes keep the previous comparison visible. Superseded file loads and LSP navigation cannot apply to the new comparison; snapshots still displayed in other panes or tabs remain read-only.
|
Refresh retains the selected path when still present, explorer query/view/visibility, logical text-side focus, and split/unified/orientation/wrap choices; moving a Working or bookmark endpoint clears review marks, while pinned checkout-only transitions retain them. Removed selections fall back to the first changed file. Bookmark replacements preserve/clamp cursor and scroll positions as replacement content loads. Failed refreshes keep the previous comparison visible. Superseded file loads and LSP navigation cannot apply to the new comparison; snapshots still displayed in other panes or tabs remain read-only.
|
||||||
|
|
||||||
The changed-file explorer is **docked on the right**. In the default split layout, modified files show Before on the left and After on the right. Added files show only After, using the full text workspace; deleted files show only Before. Rename-only files show one empty pane, with the old and new paths in the explorer; renames with text or final-newline changes retain both source sides. Before and unmatched pinned After snapshots are read-only. **Working checkout** or a checkout-matched pinned After binds eligible regular UTF-8 files to normal editor buffers, reusing already-open dirty buffers and their undo history. Editable After supports native edits, completion, undo/redo, and `:w`. Removed files, rename-only files, binary files, symlinks, and submodules remain read-only. Closing the diff keeps its buffers and unsaved edits.
|
The changed-file explorer is **docked on the right**. In the default split layout, modified files show Before on the left and After on the right. Added files show only After, using the full text workspace; deleted files show only Before. Rename-only files show one empty pane, with the old and new paths in the explorer; renames with text or final-newline changes retain both source sides. Before and unmatched pinned After snapshots are read-only. A **Follow checkout / Checkout snapshot After** or a checkout-matched pinned After binds eligible regular UTF-8 files to normal editor buffers, reusing already-open dirty buffers and their undo history. Editable After supports native edits, completion, undo/redo, and `:w`. Removed files, rename-only files, binary files, symlinks, and submodules remain read-only. Closing the diff keeps its buffers and unsaved edits.
|
||||||
|
|
||||||
Comparisons start in **split, vertical (left/right)** view by default. In Normal mode, **Space v** cycles split/unified; **Space s** switches a split between vertical (left/right) and horizontal (above/below, Before above After). Canonical commands are `:diff.view.toggle` and `:diff.orientation.toggle`; both are available in the palette and as remappable `config.core.diff.view.toggle` / `config.core.diff.orientation.toggle` actions. Configure new-comparison defaults with `config.diff_view_mode = "split"` or `"unified"` and `config.diff_split_orientation = "vertical"` or `"horizontal"`. Current choices survive file and tab switches; closing and reopening uses the configured defaults. Orientation stays unchanged while unified is active.
|
Comparisons start in **split, vertical (left/right)** view by default. In Normal mode, **Space v** cycles split/unified; **Space s** switches a split between vertical (left/right) and horizontal (above/below, Before above After). Canonical commands are `:diff.view.toggle` and `:diff.orientation.toggle`; both are available in the palette and as remappable `config.core.diff.view.toggle` / `config.core.diff.orientation.toggle` actions. Configure new-comparison defaults with `config.diff_view_mode = "split"` or `"unified"` and `config.diff_split_orientation = "vertical"` or `"horizontal"`. Current choices survive file and tab switches; closing and reopening uses the configured defaults. Orientation stays unchanged while unified is active.
|
||||||
|
|
||||||
@@ -504,8 +508,8 @@ Local review buffers (comments, summaries, overview, and stored-review selector)
|
|||||||
| `:review.verdict looks_good` | Record **Looks good** |
|
| `:review.verdict looks_good` | Record **Looks good** |
|
||||||
| `:review.verdict changes_requested` | Record **Changes requested**; `none` clears the verdict |
|
| `:review.verdict changes_requested` | Record **Changes requested**; `none` clears the verdict |
|
||||||
| `:review.save [path]` | Commit to SQLite; with a path, also export JSON for sharing |
|
| `:review.save [path]` | Commit to SQLite; with a path, also export JSON for sharing |
|
||||||
| `:review.open [id or path]` | Show stored reviews, open a stored review ID, or import a JSON review as a new review |
|
| `:review.open [id or path]` | Show reviews for the current scope, open a matching stored review ID, or import matching JSON as a new review |
|
||||||
| `:review.new` | Create a separate stored review for the current repository |
|
| `:review.new` | Create another stored review for the current comparison or ordinary-source scope |
|
||||||
|
|
||||||
File checkmarks remain diff-specific (`:diff.review.toggle`).
|
File checkmarks remain diff-specific (`:diff.review.toggle`).
|
||||||
|
|
||||||
@@ -513,17 +517,21 @@ Comment and summary text is committed only by **`:w`** or **`:review.save`**, wh
|
|||||||
|
|
||||||
`:review.copy` copies the same text as `vamp --review show ID --all`: repository, summary, verdict, counts, and every saved comment with its anchor and exact excerpt. Resolved and stale comments are included, across all files in the selected review. `clipboard_mode` chooses the system clipboard or Separate-mode internal register. Copying does not save or include unsaved comment/summary drafts. No comments or a clipboard write failure reports an error without replacing the register.
|
`:review.copy` copies the same text as `vamp --review show ID --all`: repository, summary, verdict, counts, and every saved comment with its anchor and exact excerpt. Resolved and stale comments are included, across all files in the selected review. `clipboard_mode` chooses the system clipboard or Separate-mode internal register. Copying does not save or include unsaved comment/summary drafts. No comments or a clipboard write failure reports an error without replacing the register.
|
||||||
|
|
||||||
|
CLI text output and `:review.copy` wrap each source excerpt in a Markdown code block, with its detected language tag (`hon` for `.hon`, `rust` for `.rs`, and so on). Detection uses the frozen source path and first-line shebang, not current disk contents; unknown languages use an untagged block. Fences grow beyond any backtick run in the excerpt, so nested Markdown cannot end the block early. Feedback follows outside the code block. Exact excerpt text, including existing final newlines, is preserved; a missing final newline gains only the separator required before the closing fence. JSON excerpts and stored anchors remain raw and unchanged.
|
||||||
|
|
||||||
From the shell, use `vamp --review delete REVIEW_ID COMMENT_ID [--repo PATH] [--json]` to delete a stored comment immediately without opening the editor. Find IDs with `vamp --review list` and `vamp --review show REVIEW_ID --all`. The repository defaults to the current checkout. Both resolved and unresolved comments can be deleted; the review and any JSON exports remain intact. An editor holding the old review revision must reload before saving.
|
From the shell, use `vamp --review delete REVIEW_ID COMMENT_ID [--repo PATH] [--json]` to delete a stored comment immediately without opening the editor. Find IDs with `vamp --review list` and `vamp --review show REVIEW_ID --all`. The repository defaults to the current checkout. Both resolved and unresolved comments can be deleted; the review and any JSON exports remain intact. An editor holding the old review revision must reload before saving.
|
||||||
|
|
||||||
Use `vamp --review resolve REVIEW_ID COMMENT_ID [--repo PATH] [--json]` after addressing and verifying feedback to mark it resolved without changing its body or original anchor. The command prints the updated review's remaining unresolved comments and full counts; `show REVIEW_ID --all` still includes the resolved original. Repeating resolution succeeds without a write or revision bump. Both IDs must be positive and refer to the selected repository; invalid targets and absent storage fail without creating or changing stored data. Like deletion, resolution rejects stale saves from an editor holding the previous review revision.
|
Use `vamp --review resolve REVIEW_ID COMMENT_ID [--repo PATH] [--json]` after addressing and verifying feedback to mark it resolved without changing its body or original anchor. The command prints the updated review's remaining unresolved comments and full counts; `show REVIEW_ID --all` still includes the resolved original. Repeating resolution succeeds without a write or revision bump. Both IDs must be positive and refer to the selected repository; invalid targets and absent storage fail without creating or changing stored data. Like deletion, resolution rejects stale saves from an editor holding the previous review revision.
|
||||||
|
|
||||||
Storage defaults to `$XDG_DATA_HOME/vamp/reviews/reviews.sqlite3`, or `~/.local/share/vamp/reviews/reviews.sqlite3` when XDG_DATA_HOME is unset or relative. Configure `review_directory` to override its directory; relative configuration paths resolve against the config file. Repository identity is the canonical checkout root. Each review has a persistent database ID independent of comparison revisions, so reopening changed revisions retains stale comments. Opening a comparison automatically restores the repository's sole review. Multiple reviews open a floating selector: move to an entry and press Enter, or use `:review.open id`. Use `:review.new` for another independent review.
|
Storage defaults to `$XDG_DATA_HOME/vamp/reviews/reviews.sqlite3`, or `~/.local/share/vamp/reviews/reviews.sqlite3` when XDG_DATA_HOME is unset or relative. Configure `review_directory` to override its directory; relative configuration paths resolve against the config file. Review scope is the canonical checkout root plus the ordered, resolved Before/After identities, not branch/bookmark labels. Opening a different pair selects its existing review or starts an empty one; reopening the same pair restores its feedback, verdict, and file progress. A sole matching review restores automatically; multiple reviews for that scope open a floating selector. Move to an entry and press Enter, or use `:review.open id`. `:review.new` creates another review within the same scope. Opening or importing a review from a different scope is rejected rather than reassigning its feedback.
|
||||||
|
|
||||||
|
Working edits and in-place checkout refreshes retain the comparison's initial review scope. Explicitly opening a comparison resolves its endpoints again: changed Working contents or moved bookmarks can therefore select a different review. Ordinary source-buffer comments use a separate repository-local source scope, never the last comparison's review. Unsaved review drafts block scope changes until saved or discarded. Opening an empty scope does not write storage until a review change is committed.
|
||||||
|
|
||||||
Write errors, locked databases, incompatible schemas, and conflicting edits from another editor instance retain dirty drafts and report the failure. Failed text saves leave the committed review unchanged; unrelated metadata autosaves cannot commit those drafts. Normal destructive closes remain guarded until drafts are explicitly saved or discarded. `:w!` does not bypass database revision conflicts; export your draft before reopening the current stored review. JSON exports retain external-file-change protection. Only the current database schema is accepted; existing incompatible databases are preserved rather than reset or migrated.
|
Write errors, locked databases, incompatible schemas, and conflicting edits from another editor instance retain dirty drafts and report the failure. Failed text saves leave the committed review unchanged; unrelated metadata autosaves cannot commit those drafts. Normal destructive closes remain guarded until drafts are explicitly saved or discarded. `:w!` does not bypass database revision conflicts; export your draft before reopening the current stored review. JSON exports retain external-file-change protection. Only the current database schema is accepted; existing incompatible databases are preserved rather than reset or migrated.
|
||||||
|
|
||||||
The current SQLite format is schema 3: typed `reviews`, `comments`, and `reviewed_files` tables, not serialized JSON documents. Older databases are incompatible and are left untouched; there is no migration or automatic reset. JSON version 4 is the sharing format, independent of the database schema.
|
The current SQLite format is schema 4: typed `reviews`, `comments`, and `reviewed_files` tables, not serialized JSON documents. Review rows store nullable Before/After identities; both are null for source reviews. Older databases, including schema 3, are incompatible and are left untouched; there is no migration or automatic reset. JSON version 5 is the sharing format, independent of the database schema.
|
||||||
|
|
||||||
Version 4 JSON is the agent-readable sharing format: repository root, summary, optional verdict, comments, and a required `reviewed_files` array. Each reviewed file stores its immutable per-file `revisions` pair (`file:` identities encoding mode, Git object ID or exact data, and notices), relative `path`, optional rename `old_path`, and optional exact live `after` text. Comments have stable IDs, bodies, required boolean `resolved` states, and anchors. New comments are unresolved. Database review IDs and optimistic storage revisions stay internal. Each anchor retains its immutable comparison revision, side, relative path (old path for renamed Before files), and exact whole-file text including the final newline. `start` and `end` contain zero-based source `row` and extended-grapheme `column` coordinates; `end` is exclusive. Columns count complete Unicode graphemes, not bytes, code points, UTF-16 units, or screen cells. Overview/window labels display one-based rows and columns. Older JSON formats are rejected and preserved without migration. Working comparisons use content-addressed snapshot IDs; captured text additionally freezes live unsaved edits. Stored reviews have a 64 MiB aggregate text budget including 64 bytes of overhead per comment and reviewed file; imported/exported JSON files have a separate 64 MiB serialized-size limit.
|
Version 5 JSON is the agent-readable sharing format: repository root, `comparison` (an ordered Before/After identity pair, or `null` for source reviews), summary, optional verdict, comments, and a required `reviewed_files` array. Each reviewed file stores its immutable per-file `revisions` pair (`file:` identities encoding mode, Git object ID or exact data, and notices), relative `path`, optional rename `old_path`, and optional exact live `after` text. Comments have stable IDs, bodies, required boolean `resolved` states, and anchors. New comments are unresolved. Database review IDs and optimistic storage revisions stay internal. Each anchor retains its immutable comparison revision, side, relative path (old path for renamed Before files), and exact whole-file text including the final newline. `start` and `end` contain zero-based source `row` and extended-grapheme `column` coordinates; `end` is exclusive. Columns count complete Unicode graphemes, not bytes, code points, UTF-16 units, or screen cells. Overview/window labels display one-based rows and columns. Older JSON formats are rejected and preserved without migration. Working comparisons use content-addressed snapshot IDs; captured text additionally freezes live unsaved edits. Stored reviews have a 64 MiB aggregate text budget including 64 bytes of overhead per comment and reviewed file; imported/exported JSON files have a separate 64 MiB serialized-size limit.
|
||||||
|
|
||||||
Reopening requires the same repository root. Unchanged comment ranges follow edits elsewhere in the file, including inserted/deleted lines above and column shifts before a partial-line selection. Working-snapshot refreshes can retain these attachments; unrelated immutable revisions and paths do not match. Mapping requires an unchanged, unambiguous text region containing the selection. Modified/deleted selections or ambiguous repeated regions are **stale**: comments remain available through the overview and next/previous navigation, with original snapshot coordinates and no current-source marker or jump. Saved anchors and exported excerpts remain unchanged. Unloaded files are shown as **not checked** until visited; refresh the overview to update its statuses. There is no server, authentication, or review-publishing operation.
|
Reopening requires the same repository root. Unchanged comment ranges follow edits elsewhere in the file, including inserted/deleted lines above and column shifts before a partial-line selection. Working-snapshot refreshes can retain these attachments; unrelated immutable revisions and paths do not match. Mapping requires an unchanged, unambiguous text region containing the selection. Modified/deleted selections or ambiguous repeated regions are **stale**: comments remain available through the overview and next/previous navigation, with original snapshot coordinates and no current-source marker or jump. Saved anchors and exported excerpts remain unchanged. Unloaded files are shown as **not checked** until visited; refresh the overview to update its statuses. There is no server, authentication, or review-publishing operation.
|
||||||
|
|
||||||
@@ -573,6 +581,8 @@ Run **`:resolve`** in Normal mode for the current jj working copy. It opens a de
|
|||||||
|
|
||||||
The resolver shows the editable **Resolution** on the left and a read-only **Other version** on the right, with aligned rows, line/character highlighting, context folds and synchronized scrolling. Both are projected from the current working buffer, including pre-existing unsaved edits: only conflict-marker regions are replaced by their selected alternatives. Already-merged text and edits outside those regions appear on both sides. The initial output is seeded from the first side as a normal undoable buffer change; opening never writes it to disk. References remain frozen while later edits change only the output.
|
The resolver shows the editable **Resolution** on the left and a read-only **Other version** on the right, with aligned rows, line/character highlighting, context folds and synchronized scrolling. Both are projected from the current working buffer, including pre-existing unsaved edits: only conflict-marker regions are replaced by their selected alternatives. Already-merged text and edits outside those regions appear on both sides. The initial output is seeded from the first side as a normal undoable buffer change; opening never writes it to disk. References remain frozen while later edits change only the output.
|
||||||
|
|
||||||
|
Both panes show their own changes **against the selected base**, not a left-to-right removal/addition comparison: removed base lines are red with `-`, and added candidate lines are green with `+`, on either side. Replacements show the removed base text followed by the candidate text; unchanged text stays neutral. Removed base lines are display-only, with no candidate line number, caret, or editable selection. With no base (an add/add conflict), both candidates are additions against empty text. The Resolution baseline uses the same frozen working-text projection as its alternatives, preserving already-merged text and pre-existing outside edits. `resolve.base` selects the baseline through History; returning to Resolution uses that selection. Hunk navigation and `resolve.use_source` still operate on differences between the actual Draft and Alternative.
|
||||||
|
|
||||||
Pane borders label the editable output **Resolution** and its frozen reference **Other version**. Paths and branch-comparison metadata are omitted from these titles; unsaved state belongs only to the output, and source identity appears with the reference. Explorer **C** marks files jj still considers conflicted, including while edited/checking or confirmation is unavailable; it clears only when jj confirms resolution.
|
Pane borders label the editable output **Resolution** and its frozen reference **Other version**. Paths and branch-comparison metadata are omitted from these titles; unsaved state belongs only to the output, and source identity appears with the reference. Explorer **C** marks files jj still considers conflicted, including while edited/checking or confirmation is unavailable; it clears only when jj confirms resolution.
|
||||||
|
|
||||||
| Command | Behavior |
|
| Command | Behavior |
|
||||||
@@ -588,6 +598,8 @@ Pane borders label the editable output **Resolution** and its frozen reference *
|
|||||||
|
|
||||||
**History** is separate from editing: it compares the original inputs and their base, with all sides and bases selectable for multi-sided conflicts. `config.resolve_history_layout = "columns"` (default) shows left/base/right; `"base_below"` shows left/right above a full-width base. Both layouts retain aligned changes and synchronized vertical scrolling. Tab/Shift+Tab cycles in layout order; directional pane actions follow the displayed geometry. Narrow History shows the focused source, as does base-below History when too short to display both rows of panes. Draft text, undo, caret and viewport survive view switches. Resolution uses the ordinary diff workspace, including its configured explorer, split/unified/orientation controls, source-line navigation, excerpts, focus and scrolling. Read-only Alternatives and History reject edits and saves.
|
**History** is separate from editing: it compares the original inputs and their base, with all sides and bases selectable for multi-sided conflicts. `config.resolve_history_layout = "columns"` (default) shows left/base/right; `"base_below"` shows left/right above a full-width base. Both layouts retain aligned changes and synchronized vertical scrolling. Tab/Shift+Tab cycles in layout order; directional pane actions follow the displayed geometry. Narrow History shows the focused source, as does base-below History when too short to display both rows of panes. Draft text, undo, caret and viewport survive view switches. Resolution uses the ordinary diff workspace, including its configured explorer, split/unified/orientation controls, source-line navigation, excerpts, focus and scrolling. Read-only Alternatives and History reject edits and saves.
|
||||||
|
|
||||||
|
Each outer History pane uses the same red-removal/green-addition presentation against the selected base. The base pane highlights original text changed by either input; addition-only rows leave a gap there. This applies to both History layouts.
|
||||||
|
|
||||||
Input indexes are one-based and include bases. Conflict-specific actions appear in the palette and through corresponding `config.core.resolve` methods. File navigation uses the existing picker/explorer actions: Space o opens floating search, Space e focuses the dock, Ctrl+t switches Tree/List, Ctrl+w closes it, and configured tree/selection bindings apply unchanged. Enter selects a relevant conflict file and focuses the editable Draft. Normal-mode Tab/Shift+Tab, native motions/search, context expansion and layout actions follow normal diff behavior. The complete seeded Draft can be saved even when intentional differences remain against the Alternative: those differences are not unresolved conflict markers. There is no automatic “accept both”.
|
Input indexes are one-based and include bases. Conflict-specific actions appear in the palette and through corresponding `config.core.resolve` methods. File navigation uses the existing picker/explorer actions: Space o opens floating search, Space e focuses the dock, Ctrl+t switches Tree/List, Ctrl+w closes it, and configured tree/selection bindings apply unchanged. Enter selects a relevant conflict file and focuses the editable Draft. Normal-mode Tab/Shift+Tab, native motions/search, context expansion and layout actions follow normal diff behavior. The complete seeded Draft can be saved even when intentional differences remain against the Alternative: those differences are not unresolved conflict markers. There is no automatic “accept both”.
|
||||||
|
|
||||||
**`:w` saves and checks jj**, asynchronously. Before writing, the resolver locks the working copy and validates the frozen checkout and exact on-disk file state. Checkout changes, external writes and unsafe filesystem ancestors refuse the write, including force-save. Successful writes trigger jj's normal snapshot operation, which may capture other on-disk changes. The list distinguishes unresolved, unsaved, checking, resolved and unconfirmed states. jj—not marker scanning or a manual checkbox—confirms resolution. A failed confirmation keeps saved contents and reports unconfirmed status. Resolved files remain in the session for review. Dirty drafts prevent destructive resolver/pane/tab closes.
|
**`:w` saves and checks jj**, asynchronously. Before writing, the resolver locks the working copy and validates the frozen checkout and exact on-disk file state. Checkout changes, external writes and unsafe filesystem ancestors refuse the write, including force-save. Successful writes trigger jj's normal snapshot operation, which may capture other on-disk changes. The list distinguishes unresolved, unsaved, checking, resolved and unconfirmed states. jj—not marker scanning or a manual checkbox—confirms resolution. A failed confirmation keeps saved contents and reports unconfirmed status. Resolved files remain in the session for review. Dirty drafts prevent destructive resolver/pane/tab closes.
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ Vamp.configure do |config|
|
|||||||
config.leader = "space"
|
config.leader = "space"
|
||||||
config.clipboard_mode = "system" # "separate" keeps ordinary y/d/c/p/P internal
|
config.clipboard_mode = "system" # "separate" keeps ordinary y/d/c/p/P internal
|
||||||
config.git_blame = true # false disables current-line inline blame
|
config.git_blame = true # false disables current-line inline blame
|
||||||
|
config.cursor = {} # mode colors enabled; block cursor in every mode
|
||||||
|
# config.cursor = { mode_colors: false, insert: :bar, visual: :underline }
|
||||||
config.scroll_distance = 0 # nonnegative edge distance, or nil to center
|
config.scroll_distance = 0 # nonnegative edge distance, or nil to center
|
||||||
config.scroll_step_lines = nil # Ctrl+u/d: half viewport, or a positive screen-row count
|
config.scroll_step_lines = nil # Ctrl+u/d: half viewport, or a positive screen-row count
|
||||||
config.highlight_trailing_whitespace = false # true marks line-end whitespace in red
|
config.highlight_trailing_whitespace = false # true marks line-end whitespace in red
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
use crate::editor::Mode;
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Deserialize)]
|
||||||
|
#[serde(default, deny_unknown_fields)]
|
||||||
|
pub(crate) struct CursorConfig {
|
||||||
|
pub(crate) mode_colors: bool,
|
||||||
|
pub(crate) normal: CursorShape,
|
||||||
|
pub(crate) insert: CursorShape,
|
||||||
|
pub(crate) command: CursorShape,
|
||||||
|
pub(crate) search: CursorShape,
|
||||||
|
pub(crate) visual: CursorShape,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub(crate) enum CursorShape {
|
||||||
|
Block,
|
||||||
|
Bar,
|
||||||
|
Underline,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for CursorConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
mode_colors: true,
|
||||||
|
normal: CursorShape::Block,
|
||||||
|
insert: CursorShape::Block,
|
||||||
|
command: CursorShape::Block,
|
||||||
|
search: CursorShape::Block,
|
||||||
|
visual: CursorShape::Block,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CursorConfig {
|
||||||
|
pub(crate) fn shape(self, mode: Mode) -> CursorShape {
|
||||||
|
match mode {
|
||||||
|
Mode::Normal => self.normal,
|
||||||
|
Mode::Insert => self.insert,
|
||||||
|
Mode::Command => self.command,
|
||||||
|
Mode::Search => self.search,
|
||||||
|
Mode::Visual => self.visual,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+401
-92
@@ -96,10 +96,22 @@ pub struct Revision {
|
|||||||
pub description: String,
|
pub description: String,
|
||||||
pub category: Category,
|
pub category: Category,
|
||||||
}
|
}
|
||||||
|
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||||
|
pub struct RevisionGraph {
|
||||||
|
pub change_id: String,
|
||||||
|
pub description: String,
|
||||||
|
pub parents: Vec<String>,
|
||||||
|
pub author: String,
|
||||||
|
pub timestamp: String,
|
||||||
|
pub bookmarks: String,
|
||||||
|
pub current: bool,
|
||||||
|
pub immutable: bool,
|
||||||
|
}
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct Catalog {
|
pub struct Catalog {
|
||||||
pub kind: RepoKind,
|
pub kind: RepoKind,
|
||||||
pub entries: Vec<Revision>,
|
pub entries: Vec<Revision>,
|
||||||
|
pub graph: BTreeMap<String, RevisionGraph>,
|
||||||
pub notice: Option<String>,
|
pub notice: Option<String>,
|
||||||
}
|
}
|
||||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||||
@@ -356,6 +368,9 @@ pub(crate) struct ColumnLine<'a> {
|
|||||||
pub side: Focus,
|
pub side: Focus,
|
||||||
pub source_row: usize,
|
pub source_row: usize,
|
||||||
pub numbers: [Option<usize>; 2],
|
pub numbers: [Option<usize>; 2],
|
||||||
|
/// Change polarity is independent of the native source/pane.
|
||||||
|
pub polarity: Focus,
|
||||||
|
pub native: bool,
|
||||||
pub changed: bool,
|
pub changed: bool,
|
||||||
pub emphasize: bool,
|
pub emphasize: bool,
|
||||||
pub newline: bool,
|
pub newline: bool,
|
||||||
@@ -409,6 +424,46 @@ impl<'a> ColumnView<'a> {
|
|||||||
DisplayRow::Line(index) => index,
|
DisplayRow::Line(index) => index,
|
||||||
};
|
};
|
||||||
let row = self.diff.rows.get(index)?;
|
let row = self.diff.rows.get(index)?;
|
||||||
|
let side = self.diff.display_focus(display).unwrap_or(self.info.focus);
|
||||||
|
if let Some(conflict) = &self.diff.conflict {
|
||||||
|
let Some(line) = &conflict.rows[index][usize::from(side == Focus::After)] else {
|
||||||
|
return Some(ColumnRow::Gap);
|
||||||
|
};
|
||||||
|
let source = if line.removed {
|
||||||
|
&conflict.base
|
||||||
|
} else if side == Focus::Before {
|
||||||
|
&self.file.before
|
||||||
|
} else {
|
||||||
|
&self.file.after
|
||||||
|
};
|
||||||
|
return Some(ColumnRow::Text(ColumnLine {
|
||||||
|
text: source.get(line.source).map(String::as_str).or(Some("")),
|
||||||
|
side,
|
||||||
|
source_row: line.source,
|
||||||
|
numbers: if line.removed {
|
||||||
|
[None; 2]
|
||||||
|
} else {
|
||||||
|
[row.before, row.after]
|
||||||
|
},
|
||||||
|
polarity: if line.removed {
|
||||||
|
Focus::Before
|
||||||
|
} else {
|
||||||
|
Focus::After
|
||||||
|
},
|
||||||
|
native: !line.removed,
|
||||||
|
changed: line.changed,
|
||||||
|
emphasize: true,
|
||||||
|
newline: line.source + 1 < source.len()
|
||||||
|
|| if line.removed {
|
||||||
|
conflict.base.last().is_some_and(String::is_empty)
|
||||||
|
} else if side == Focus::Before {
|
||||||
|
self.file.before_eof
|
||||||
|
} else {
|
||||||
|
self.file.after_eof
|
||||||
|
},
|
||||||
|
changes: Cow::Borrowed(&line.changes),
|
||||||
|
}));
|
||||||
|
}
|
||||||
let side = if self.info.unified {
|
let side = if self.info.unified {
|
||||||
self.diff.display_focus(display).unwrap_or(self.info.focus)
|
self.diff.display_focus(display).unwrap_or(self.info.focus)
|
||||||
} else {
|
} else {
|
||||||
@@ -436,6 +491,8 @@ impl<'a> ColumnView<'a> {
|
|||||||
self.diff.display_source_row(Focus::Before, display),
|
self.diff.display_source_row(Focus::Before, display),
|
||||||
self.diff.display_source_row(Focus::After, display),
|
self.diff.display_source_row(Focus::After, display),
|
||||||
],
|
],
|
||||||
|
polarity: side,
|
||||||
|
native: true,
|
||||||
changed: row.kind == RowKind::Changed
|
changed: row.kind == RowKind::Changed
|
||||||
|| (before && row.kind == RowKind::Removed)
|
|| (before && row.kind == RowKind::Removed)
|
||||||
|| (!before && row.kind == RowKind::Added),
|
|| (!before && row.kind == RowKind::Added),
|
||||||
@@ -459,6 +516,35 @@ impl<'a> ColumnView<'a> {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Wrapping includes display-only removals without computing character diffs.
|
||||||
|
/// Removed base rows occupy a disjoint cache-key space from native rows.
|
||||||
|
pub fn text_row(&self, display: usize) -> Option<(usize, &'a str)> {
|
||||||
|
let DisplayRow::Line(index) = *self.diff.display_rows.get(display)? else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
let side = self.info.focus;
|
||||||
|
if self
|
||||||
|
.diff
|
||||||
|
.display_focus(display)
|
||||||
|
.is_some_and(|focus| focus != side)
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if let Some(conflict) = &self.diff.conflict {
|
||||||
|
let line = conflict.rows[index][usize::from(side == Focus::After)].as_ref()?;
|
||||||
|
if line.removed {
|
||||||
|
return Some((usize::MAX - line.source, &conflict.base[line.source]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let source = self.diff.display_source_row(side, display)?;
|
||||||
|
let lines = if side == Focus::Before {
|
||||||
|
&self.file.before
|
||||||
|
} else {
|
||||||
|
&self.file.after
|
||||||
|
};
|
||||||
|
Some((source, lines.get(source).map_or("", String::as_str)))
|
||||||
|
}
|
||||||
|
|
||||||
/// Caret mapping stays on this view's native side, even in unified display.
|
/// Caret mapping stays on this view's native side, even in unified display.
|
||||||
pub fn native_row(&self, display: usize) -> Option<usize> {
|
pub fn native_row(&self, display: usize) -> Option<usize> {
|
||||||
self.diff.display_source_row(self.info.focus, display)
|
self.diff.display_source_row(self.info.focus, display)
|
||||||
@@ -489,9 +575,105 @@ impl<'a> ColumnView<'a> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Base-relative candidate patches with display-only removed base lines.
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct ConflictChanges {
|
||||||
|
base: Vec<String>,
|
||||||
|
rows: Vec<[Option<ConflictLine>; 2]>,
|
||||||
|
base_rows: Vec<Option<usize>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct ConflictLine {
|
||||||
|
source: usize,
|
||||||
|
removed: bool,
|
||||||
|
changed: bool,
|
||||||
|
changes: Vec<[std::ops::Range<usize>; 2]>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConflictChanges {
|
||||||
|
fn new(base: &[String], candidates: [&[String]; 2]) -> Self {
|
||||||
|
let mut pairs = candidates.map(|candidate| {
|
||||||
|
let mut diff = DiffState::new();
|
||||||
|
diff.set_resolver_text(base, candidate, None);
|
||||||
|
diff
|
||||||
|
});
|
||||||
|
for pair in &mut pairs {
|
||||||
|
if pair.lazy_changes {
|
||||||
|
for row in &mut pair.rows {
|
||||||
|
if row.kind == RowKind::Changed {
|
||||||
|
// Large inputs keep the existing whole-line fallback instead of
|
||||||
|
// eagerly running a character diff for every changed row.
|
||||||
|
row.changes = vec![[0..usize::MAX, 0..usize::MAX]];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let [left, right] = pairs.map(|pair| pair.rows.into_iter().peekable());
|
||||||
|
let (mut left, mut right) = (left, right);
|
||||||
|
let mut result = Self {
|
||||||
|
base: base.to_vec(),
|
||||||
|
rows: Vec::new(),
|
||||||
|
base_rows: Vec::new(),
|
||||||
|
};
|
||||||
|
while left.peek().is_some() || right.peek().is_some() {
|
||||||
|
let insertion = left.peek().is_some_and(|row| row.before.is_none())
|
||||||
|
|| right.peek().is_some_and(|row| row.before.is_none());
|
||||||
|
let rows = [&mut left, &mut right].map(|rows| {
|
||||||
|
if rows
|
||||||
|
.peek()
|
||||||
|
.is_some_and(|row| !insertion || row.before.is_none())
|
||||||
|
{
|
||||||
|
rows.next()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let base_row = rows.iter().flatten().find_map(|row| row.before);
|
||||||
|
let removal =
|
||||||
|
base_row.is_some() && rows.iter().flatten().any(|row| row.kind != RowKind::Equal);
|
||||||
|
let line = |row: &AlignedRow, removed: bool| {
|
||||||
|
let source = if removed { row.before } else { row.after }?;
|
||||||
|
let side = usize::from(!removed);
|
||||||
|
Some(ConflictLine {
|
||||||
|
source,
|
||||||
|
removed,
|
||||||
|
changed: row.kind != RowKind::Equal,
|
||||||
|
changes: row
|
||||||
|
.changes
|
||||||
|
.iter()
|
||||||
|
.map(|change| [change[side].clone(), change[side].clone()])
|
||||||
|
.collect(),
|
||||||
|
})
|
||||||
|
};
|
||||||
|
if removal {
|
||||||
|
result.rows.push(std::array::from_fn(|side| {
|
||||||
|
let row = rows[side].as_ref()?;
|
||||||
|
line(row, row.kind != RowKind::Equal)
|
||||||
|
}));
|
||||||
|
result.base_rows.push(base_row);
|
||||||
|
}
|
||||||
|
let additions = std::array::from_fn(|side| {
|
||||||
|
let row = rows[side].as_ref()?;
|
||||||
|
if removal && row.kind == RowKind::Equal {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
line(row, false)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if additions.iter().any(Option::is_some) {
|
||||||
|
result.rows.push(additions);
|
||||||
|
result.base_rows.push(if removal { None } else { base_row });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Immutable left/base/right conflict History, aligned at shared base rows.
|
/// Immutable left/base/right conflict History, aligned at shared base rows.
|
||||||
pub(crate) struct HistoryComparison {
|
pub(crate) struct HistoryComparison {
|
||||||
columns: [DiffState; 3],
|
candidates: DiffState,
|
||||||
|
base: DiffState,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A read-only projection for the existing diff painter.
|
/// A read-only projection for the existing diff painter.
|
||||||
@@ -499,7 +681,6 @@ pub(crate) struct HistoryColumn<'a> {
|
|||||||
pub diff: &'a DiffState,
|
pub diff: &'a DiffState,
|
||||||
pub file: &'a ChangedFile,
|
pub file: &'a ChangedFile,
|
||||||
pub focus: Focus,
|
pub focus: Focus,
|
||||||
pub lines: &'a [String],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> HistoryColumn<'a> {
|
impl<'a> HistoryColumn<'a> {
|
||||||
@@ -514,92 +695,54 @@ impl<'a> HistoryColumn<'a> {
|
|||||||
|
|
||||||
impl HistoryComparison {
|
impl HistoryComparison {
|
||||||
pub fn new(sources: [&str; 3]) -> Self {
|
pub fn new(sources: [&str; 3]) -> Self {
|
||||||
let [left, base, right] =
|
let [left, base_lines, right] =
|
||||||
sources.map(|text| text.split('\n').map(str::to_owned).collect::<Vec<_>>());
|
sources.map(|text| text.split('\n').map(str::to_owned).collect::<Vec<_>>());
|
||||||
let mut columns: [DiffState; 3] = std::array::from_fn(|_| DiffState::new());
|
let mut candidates = DiffState::new();
|
||||||
columns[0].set_resolver_text(&base, &left);
|
candidates.set_resolver_text(&left, &right, Some(&base_lines));
|
||||||
columns[1].set_resolver_text(&base, &base);
|
let mut base = DiffState::new();
|
||||||
columns[2].set_resolver_text(&base, &right);
|
base.set_resolver_text(&base_lines, &base_lines, None);
|
||||||
let mut left = std::mem::take(&mut columns[0].rows).into_iter().peekable();
|
let conflict = candidates.conflict.as_ref().unwrap();
|
||||||
let mut right = std::mem::take(&mut columns[2].rows).into_iter().peekable();
|
base.rows = conflict
|
||||||
columns[1].rows.clear();
|
.base_rows
|
||||||
// Stitch the existing pairwise alignments at their shared base rows.
|
.iter()
|
||||||
while left.peek().is_some() || right.peek().is_some() {
|
.zip(&candidates.rows)
|
||||||
let insertion = left.peek().is_some_and(|row| row.before.is_none())
|
.zip(&conflict.rows)
|
||||||
|| right.peek().is_some_and(|row| row.before.is_none());
|
.map(|((&source, row), lines)| AlignedRow {
|
||||||
let take_left = left
|
before: source,
|
||||||
.peek()
|
after: source,
|
||||||
.is_some_and(|row| !insertion || row.before.is_none());
|
kind: row.kind,
|
||||||
let take_right = right
|
changes: lines
|
||||||
.peek()
|
.iter()
|
||||||
.is_some_and(|row| !insertion || row.before.is_none());
|
.flatten()
|
||||||
let empty = || AlignedRow {
|
.filter(|line| line.removed)
|
||||||
before: None,
|
.flat_map(|line| line.changes.iter().cloned())
|
||||||
after: None,
|
.collect(),
|
||||||
kind: RowKind::Equal,
|
})
|
||||||
changes: Vec::new(),
|
.collect();
|
||||||
};
|
for column in [&mut candidates, &mut base] {
|
||||||
let left_row = if take_left {
|
|
||||||
left.next().unwrap()
|
|
||||||
} else {
|
|
||||||
empty()
|
|
||||||
};
|
|
||||||
let right_row = if take_right {
|
|
||||||
right.next().unwrap()
|
|
||||||
} else {
|
|
||||||
empty()
|
|
||||||
};
|
|
||||||
let base = left_row.before.or(right_row.before);
|
|
||||||
let changed = left_row.kind != RowKind::Equal || right_row.kind != RowKind::Equal;
|
|
||||||
let changes = left_row
|
|
||||||
.changes
|
|
||||||
.iter()
|
|
||||||
.chain(&right_row.changes)
|
|
||||||
.map(|change| [change[0].clone(), change[0].clone()])
|
|
||||||
.collect();
|
|
||||||
columns[1].rows.push(AlignedRow {
|
|
||||||
before: base,
|
|
||||||
after: base,
|
|
||||||
kind: if changed {
|
|
||||||
RowKind::Changed
|
|
||||||
} else {
|
|
||||||
RowKind::Equal
|
|
||||||
},
|
|
||||||
changes,
|
|
||||||
});
|
|
||||||
columns[0].rows.push(left_row);
|
|
||||||
columns[2].rows.push(right_row);
|
|
||||||
}
|
|
||||||
for column in &mut columns {
|
|
||||||
column.display_rows = (0..column.rows.len()).map(DisplayRow::Line).collect();
|
column.display_rows = (0..column.rows.len()).map(DisplayRow::Line).collect();
|
||||||
column.rebuild_source_ranges();
|
column.rebuild_source_ranges();
|
||||||
}
|
}
|
||||||
Self { columns }
|
Self { candidates, base }
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn row_count(&self) -> usize {
|
pub fn row_count(&self) -> usize {
|
||||||
self.columns[0].rows.len()
|
self.candidates.rows.len()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn column(&self, index: usize) -> HistoryColumn<'_> {
|
pub fn column(&self, index: usize) -> HistoryColumn<'_> {
|
||||||
let diff = &self.columns[index];
|
let diff = if index == 1 {
|
||||||
|
&self.base
|
||||||
|
} else {
|
||||||
|
&self.candidates
|
||||||
|
};
|
||||||
let file = &diff.comparison.as_ref().unwrap().files[0];
|
let file = &diff.comparison.as_ref().unwrap().files[0];
|
||||||
let focus = if index == 1 {
|
let focus = if index == 2 {
|
||||||
Focus::Before
|
|
||||||
} else {
|
|
||||||
Focus::After
|
Focus::After
|
||||||
};
|
|
||||||
let lines = if focus == Focus::Before {
|
|
||||||
&file.before
|
|
||||||
} else {
|
} else {
|
||||||
&file.after
|
Focus::Before
|
||||||
};
|
};
|
||||||
HistoryColumn {
|
HistoryColumn { diff, file, focus }
|
||||||
diff,
|
|
||||||
file,
|
|
||||||
focus,
|
|
||||||
lines,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -630,6 +773,7 @@ pub struct DiffState {
|
|||||||
pub word_wrap: bool,
|
pub word_wrap: bool,
|
||||||
pub view_mode: ViewMode,
|
pub view_mode: ViewMode,
|
||||||
pub comparison_mode: ComparisonMode,
|
pub comparison_mode: ComparisonMode,
|
||||||
|
pub follow_working_copy: bool,
|
||||||
pub split_axis: crate::panes::SplitAxis,
|
pub split_axis: crate::panes::SplitAxis,
|
||||||
pub writable_side: Option<Focus>,
|
pub writable_side: Option<Focus>,
|
||||||
pub side_labels: [&'static str; 2],
|
pub side_labels: [&'static str; 2],
|
||||||
@@ -642,6 +786,9 @@ pub struct DiffState {
|
|||||||
pub error: Option<String>,
|
pub error: Option<String>,
|
||||||
pub comparison: Option<Comparison>,
|
pub comparison: Option<Comparison>,
|
||||||
pub comparison_id: Option<u64>,
|
pub comparison_id: Option<u64>,
|
||||||
|
/// Frozen initial endpoint identities; checkout refreshes do not select a new review.
|
||||||
|
pub(crate) review_comparison: Option<[String; 2]>,
|
||||||
|
review_root: Option<PathBuf>,
|
||||||
pub reviewed_files: BTreeSet<usize>,
|
pub reviewed_files: BTreeSet<usize>,
|
||||||
pub(crate) snapshot_roots: [Option<PathBuf>; 2],
|
pub(crate) snapshot_roots: [Option<PathBuf>; 2],
|
||||||
pub files: Option<FilePicker>,
|
pub files: Option<FilePicker>,
|
||||||
@@ -660,6 +807,7 @@ pub struct DiffState {
|
|||||||
lazy_changes: bool,
|
lazy_changes: bool,
|
||||||
/// Native empty terminal rows explicitly supplied by resolver text.
|
/// Native empty terminal rows explicitly supplied by resolver text.
|
||||||
terminal_rows: [Option<usize>; 2],
|
terminal_rows: [Option<usize>; 2],
|
||||||
|
conflict: Option<ConflictChanges>,
|
||||||
excerpt_ranges: Vec<(usize, usize)>,
|
excerpt_ranges: Vec<(usize, usize)>,
|
||||||
pub live_buffer: Option<usize>,
|
pub live_buffer: Option<usize>,
|
||||||
pub live_revision: Option<u64>,
|
pub live_revision: Option<u64>,
|
||||||
@@ -673,6 +821,7 @@ pub struct DiffState {
|
|||||||
pending: Option<Operation>,
|
pending: Option<Operation>,
|
||||||
request_kind: Option<RequestKind>,
|
request_kind: Option<RequestKind>,
|
||||||
visible: Vec<usize>,
|
visible: Vec<usize>,
|
||||||
|
bookmark_graph: BTreeMap<String, BTreeMap<Option<String>, String>>,
|
||||||
search_fields: Vec<[String; 3]>,
|
search_fields: Vec<[String; 3]>,
|
||||||
search_matches: Vec<([usize; 7], usize)>,
|
search_matches: Vec<([usize; 7], usize)>,
|
||||||
max_before: usize,
|
max_before: usize,
|
||||||
@@ -696,6 +845,7 @@ impl DiffState {
|
|||||||
word_wrap: false,
|
word_wrap: false,
|
||||||
view_mode: ViewMode::Split,
|
view_mode: ViewMode::Split,
|
||||||
comparison_mode: ComparisonMode::default(),
|
comparison_mode: ComparisonMode::default(),
|
||||||
|
follow_working_copy: false,
|
||||||
split_axis: crate::panes::SplitAxis::Vertical,
|
split_axis: crate::panes::SplitAxis::Vertical,
|
||||||
writable_side: Some(Focus::After),
|
writable_side: Some(Focus::After),
|
||||||
side_labels: ["Before", "After"],
|
side_labels: ["Before", "After"],
|
||||||
@@ -708,6 +858,8 @@ impl DiffState {
|
|||||||
error: None,
|
error: None,
|
||||||
comparison: None,
|
comparison: None,
|
||||||
comparison_id: None,
|
comparison_id: None,
|
||||||
|
review_comparison: None,
|
||||||
|
review_root: None,
|
||||||
reviewed_files: BTreeSet::new(),
|
reviewed_files: BTreeSet::new(),
|
||||||
files: None,
|
files: None,
|
||||||
snapshot_roots: [None, None],
|
snapshot_roots: [None, None],
|
||||||
@@ -723,6 +875,7 @@ impl DiffState {
|
|||||||
display_rows: Vec::new(),
|
display_rows: Vec::new(),
|
||||||
display_sides: Vec::new(),
|
display_sides: Vec::new(),
|
||||||
source_ranges: Default::default(),
|
source_ranges: Default::default(),
|
||||||
|
conflict: None,
|
||||||
lazy_changes: false,
|
lazy_changes: false,
|
||||||
terminal_rows: [None; 2],
|
terminal_rows: [None; 2],
|
||||||
excerpt_ranges: Vec::new(),
|
excerpt_ranges: Vec::new(),
|
||||||
@@ -737,6 +890,7 @@ impl DiffState {
|
|||||||
pending: Some(Operation::Discover),
|
pending: Some(Operation::Discover),
|
||||||
request_kind: Some(RequestKind::Catalog),
|
request_kind: Some(RequestKind::Catalog),
|
||||||
visible: Vec::new(),
|
visible: Vec::new(),
|
||||||
|
bookmark_graph: BTreeMap::new(),
|
||||||
search_fields: Vec::new(),
|
search_fields: Vec::new(),
|
||||||
search_matches: Vec::new(),
|
search_matches: Vec::new(),
|
||||||
max_before: 0,
|
max_before: 0,
|
||||||
@@ -786,7 +940,12 @@ impl DiffState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn set_resolver_text(&mut self, draft: &[String], alternative: &[String]) {
|
pub(crate) fn set_resolver_text(
|
||||||
|
&mut self,
|
||||||
|
draft: &[String],
|
||||||
|
alternative: &[String],
|
||||||
|
base: Option<&[String]>,
|
||||||
|
) {
|
||||||
fn content(lines: &[String]) -> (&[String], bool) {
|
fn content(lines: &[String]) -> (&[String], bool) {
|
||||||
let eof = lines.len() > 1 && lines.last().is_some_and(String::is_empty);
|
let eof = lines.len() > 1 && lines.last().is_some_and(String::is_empty);
|
||||||
let content = if eof {
|
let content = if eof {
|
||||||
@@ -809,6 +968,11 @@ impl DiffState {
|
|||||||
let after_terminal = (alternative.len() > after.len()).then_some(after.len());
|
let after_terminal = (alternative.len() > after.len()).then_some(after.len());
|
||||||
if self.phase == Phase::View
|
if self.phase == Phase::View
|
||||||
&& self.terminal_rows == [before_terminal, after_terminal]
|
&& self.terminal_rows == [before_terminal, after_terminal]
|
||||||
|
&& self
|
||||||
|
.conflict
|
||||||
|
.as_ref()
|
||||||
|
.map(|conflict| conflict.base.as_slice())
|
||||||
|
== base
|
||||||
&& self.comparison.as_ref().is_some_and(|comparison| {
|
&& self.comparison.as_ref().is_some_and(|comparison| {
|
||||||
let file = &comparison.files[self.file];
|
let file = &comparison.files[self.file];
|
||||||
file.before == before
|
file.before == before
|
||||||
@@ -836,15 +1000,59 @@ impl DiffState {
|
|||||||
file.before_eof = before_eof;
|
file.before_eof = before_eof;
|
||||||
file.after_eof = after_eof;
|
file.after_eof = after_eof;
|
||||||
file.hunks = line_hunks(before, before_eof, after, after_eof);
|
file.hunks = line_hunks(before, before_eof, after, after_eof);
|
||||||
self.align_file();
|
if base.is_none() {
|
||||||
|
self.align_file();
|
||||||
|
if before_terminal.is_some() || after_terminal.is_some() {
|
||||||
|
self.rows.push(AlignedRow {
|
||||||
|
before: before_terminal,
|
||||||
|
after: after_terminal,
|
||||||
|
kind: RowKind::Equal,
|
||||||
|
changes: Vec::new(),
|
||||||
|
});
|
||||||
|
self.reset_excerpts();
|
||||||
|
}
|
||||||
|
}
|
||||||
self.terminal_rows = [before_terminal, after_terminal];
|
self.terminal_rows = [before_terminal, after_terminal];
|
||||||
if before_terminal.is_some() || after_terminal.is_some() {
|
if let Some(base) = base {
|
||||||
self.rows.push(AlignedRow {
|
let conflict = ConflictChanges::new(base, [draft, alternative]);
|
||||||
before: before_terminal,
|
self.rows = conflict
|
||||||
after: after_terminal,
|
.rows
|
||||||
kind: RowKind::Equal,
|
.iter()
|
||||||
changes: Vec::new(),
|
.map(|lines| {
|
||||||
});
|
let native = |side: usize| {
|
||||||
|
lines[side]
|
||||||
|
.as_ref()
|
||||||
|
.filter(|line| !line.removed)
|
||||||
|
.map(|line| line.source)
|
||||||
|
};
|
||||||
|
AlignedRow {
|
||||||
|
before: native(0),
|
||||||
|
after: native(1),
|
||||||
|
kind: if lines.iter().flatten().any(|line| line.changed) {
|
||||||
|
RowKind::Changed
|
||||||
|
} else {
|
||||||
|
RowKind::Equal
|
||||||
|
},
|
||||||
|
changes: Vec::new(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let base_width = base.iter().map(|line| line_width(line)).max().unwrap_or(0);
|
||||||
|
self.max_before = draft
|
||||||
|
.iter()
|
||||||
|
.map(|line| line_width(line))
|
||||||
|
.max()
|
||||||
|
.unwrap_or(0)
|
||||||
|
.max(base_width);
|
||||||
|
self.max_after = alternative
|
||||||
|
.iter()
|
||||||
|
.map(|line| line_width(line))
|
||||||
|
.max()
|
||||||
|
.unwrap_or(0)
|
||||||
|
.max(base_width);
|
||||||
|
self.lazy_changes = false;
|
||||||
|
self.after_widths.clear();
|
||||||
|
self.conflict = Some(conflict);
|
||||||
self.reset_excerpts();
|
self.reset_excerpts();
|
||||||
}
|
}
|
||||||
if self.comparison.as_ref().unwrap().files[self.file]
|
if self.comparison.as_ref().unwrap().files[self.file]
|
||||||
@@ -986,6 +1194,7 @@ impl DiffState {
|
|||||||
kind: page.kind,
|
kind: page.kind,
|
||||||
entries: Vec::new(),
|
entries: Vec::new(),
|
||||||
notice: Some(format!("Pull requests from {}", page.remote)),
|
notice: Some(format!("Pull requests from {}", page.remote)),
|
||||||
|
graph: BTreeMap::new(),
|
||||||
});
|
});
|
||||||
for (revision, metadata) in page.entries {
|
for (revision, metadata) in page.entries {
|
||||||
let RevisionTarget::PullRequest { number, .. } = &revision.target else {
|
let RevisionTarget::PullRequest { number, .. } = &revision.target else {
|
||||||
@@ -1034,6 +1243,13 @@ impl DiffState {
|
|||||||
}
|
}
|
||||||
Ok(Outcome::CheckoutRefresh(Some(mut comparison)))
|
Ok(Outcome::CheckoutRefresh(Some(mut comparison)))
|
||||||
| Ok(Outcome::BookmarkRefresh(mut comparison)) => {
|
| Ok(Outcome::BookmarkRefresh(mut comparison)) => {
|
||||||
|
if bookmark_refresh {
|
||||||
|
self.review_root = Some(
|
||||||
|
std::fs::canonicalize(&comparison.root)
|
||||||
|
.unwrap_or_else(|_| comparison.root.clone()),
|
||||||
|
);
|
||||||
|
self.review_comparison = Some(comparison.revisions.clone());
|
||||||
|
}
|
||||||
let pinned = !bookmark_refresh
|
let pinned = !bookmark_refresh
|
||||||
&& self
|
&& self
|
||||||
.after
|
.after
|
||||||
@@ -1157,6 +1373,10 @@ impl DiffState {
|
|||||||
self.error = None;
|
self.error = None;
|
||||||
}
|
}
|
||||||
Ok(Outcome::Comparison(comparison)) if expected == Some(RequestKind::Comparison) => {
|
Ok(Outcome::Comparison(comparison)) if expected == Some(RequestKind::Comparison) => {
|
||||||
|
self.review_root = Some(
|
||||||
|
std::fs::canonicalize(&comparison.root)
|
||||||
|
.unwrap_or_else(|_| comparison.root.clone()),
|
||||||
|
);
|
||||||
self.live_files.clear();
|
self.live_files.clear();
|
||||||
self.modified_after.clear();
|
self.modified_after.clear();
|
||||||
self.frozen_after.clear();
|
self.frozen_after.clear();
|
||||||
@@ -1177,6 +1397,7 @@ impl DiffState {
|
|||||||
files.reveal_path(&file.path);
|
files.reveal_path(&file.path);
|
||||||
}
|
}
|
||||||
self.files = Some(files);
|
self.files = Some(files);
|
||||||
|
self.review_comparison = Some(comparison.revisions.clone());
|
||||||
self.comparison = Some(comparison);
|
self.comparison = Some(comparison);
|
||||||
self.comparison_id = Some(id);
|
self.comparison_id = Some(id);
|
||||||
self.snapshot_roots = [None, None];
|
self.snapshot_roots = [None, None];
|
||||||
@@ -1232,6 +1453,20 @@ impl DiffState {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
fn prepare_search(&mut self) {
|
fn prepare_search(&mut self) {
|
||||||
|
self.bookmark_graph.clear();
|
||||||
|
if let Some(catalog) = &self.catalog {
|
||||||
|
for entry in &catalog.entries {
|
||||||
|
if let RevisionTarget::Bookmark { name, remote } = &entry.target
|
||||||
|
&& let Some(id) = entry.description.split_whitespace().nth(1)
|
||||||
|
&& catalog.graph.contains_key(id)
|
||||||
|
{
|
||||||
|
self.bookmark_graph
|
||||||
|
.entry(name.clone())
|
||||||
|
.or_default()
|
||||||
|
.insert(remote.clone(), id.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
self.search_fields = self
|
self.search_fields = self
|
||||||
.catalog
|
.catalog
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -1348,6 +1583,21 @@ impl DiffState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn graph_revision(&self, revision: &Revision) -> Option<(&str, &RevisionGraph)> {
|
||||||
|
let catalog = self.catalog.as_ref()?;
|
||||||
|
let id = match &revision.target {
|
||||||
|
RevisionTarget::Commit(id) => id.as_str(),
|
||||||
|
RevisionTarget::Bookmark { name, remote } => {
|
||||||
|
self.bookmark_graph.get(name)?.get(remote)?
|
||||||
|
}
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
catalog
|
||||||
|
.graph
|
||||||
|
.get_key_value(id)
|
||||||
|
.map(|(id, graph)| (id.as_str(), graph))
|
||||||
|
}
|
||||||
|
|
||||||
fn refilter(&mut self) {
|
fn refilter(&mut self) {
|
||||||
self.visible.clear();
|
self.visible.clear();
|
||||||
self.search_matches.clear();
|
self.search_matches.clear();
|
||||||
@@ -1415,7 +1665,7 @@ impl DiffState {
|
|||||||
self.search_matches.push((score, index));
|
self.search_matches.push((score, index));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if self.pull_request_remote.is_none() {
|
if self.pull_request_remote.is_none() && catalog.kind == RepoKind::Git {
|
||||||
self.search_matches
|
self.search_matches
|
||||||
.sort_unstable_by_key(|&(score, index)| (score, index));
|
.sort_unstable_by_key(|&(score, index)| (score, index));
|
||||||
}
|
}
|
||||||
@@ -1459,6 +1709,31 @@ impl DiffState {
|
|||||||
b_number.cmp(&a_number)
|
b_number.cmp(&a_number)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if self.pull_request_remote.is_none() {
|
||||||
|
if let Some(before) = &self.before {
|
||||||
|
let same = |entry: &Revision| {
|
||||||
|
entry.target == before.target
|
||||||
|
|| self
|
||||||
|
.graph_revision(entry)
|
||||||
|
.zip(self.graph_revision(before))
|
||||||
|
.is_some_and(|(a, b)| a.0 == b.0)
|
||||||
|
};
|
||||||
|
if !self
|
||||||
|
.visible
|
||||||
|
.iter()
|
||||||
|
.any(|&index| same(&catalog.entries[index]))
|
||||||
|
&& let Some(index) = catalog
|
||||||
|
.entries
|
||||||
|
.iter()
|
||||||
|
.position(|entry| entry.target == before.target)
|
||||||
|
{
|
||||||
|
self.visible.push(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if catalog.kind == RepoKind::Jujutsu {
|
||||||
|
self.visible.sort_unstable();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
self.selected = 0;
|
self.selected = 0;
|
||||||
}
|
}
|
||||||
@@ -1634,7 +1909,7 @@ impl DiffState {
|
|||||||
|
|
||||||
fn choose(&mut self) {
|
fn choose(&mut self) {
|
||||||
let revision = self.entries().nth(self.selected).cloned();
|
let revision = self.entries().nth(self.selected).cloned();
|
||||||
let Some(revision) = revision else {
|
let Some(mut revision) = revision else {
|
||||||
self.retry_pull_requests();
|
self.retry_pull_requests();
|
||||||
// Listing can be retried without closing the modal.
|
// Listing can be retried without closing the modal.
|
||||||
if self.catalog.is_none()
|
if self.catalog.is_none()
|
||||||
@@ -1657,6 +1932,12 @@ impl DiffState {
|
|||||||
}
|
}
|
||||||
if revision.target == RevisionTarget::Working {
|
if revision.target == RevisionTarget::Working {
|
||||||
self.comparison_mode = ComparisonMode::Snapshots;
|
self.comparison_mode = ComparisonMode::Snapshots;
|
||||||
|
revision.label = if self.phase == Phase::After && self.follow_working_copy {
|
||||||
|
"Follow checkout"
|
||||||
|
} else {
|
||||||
|
"Checkout snapshot"
|
||||||
|
}
|
||||||
|
.into();
|
||||||
}
|
}
|
||||||
if let RevisionTarget::FetchRemote(remote) = &revision.target {
|
if let RevisionTarget::FetchRemote(remote) = &revision.target {
|
||||||
self.queue(Operation::FetchRemote(remote.clone()));
|
self.queue(Operation::FetchRemote(remote.clone()));
|
||||||
@@ -1687,7 +1968,7 @@ impl DiffState {
|
|||||||
return Err("Select a pull request before changing comparison mode");
|
return Err("Select a pull request before changing comparison mode");
|
||||||
}
|
}
|
||||||
if self.working_selected() {
|
if self.working_selected() {
|
||||||
return Err("Working checkout requires Snapshots");
|
return Err("Checkout selections require Snapshots");
|
||||||
}
|
}
|
||||||
self.comparison_mode = match self.comparison_mode {
|
self.comparison_mode = match self.comparison_mode {
|
||||||
ComparisonMode::BranchChanges => ComparisonMode::Snapshots,
|
ComparisonMode::BranchChanges => ComparisonMode::Snapshots,
|
||||||
@@ -1708,6 +1989,7 @@ impl DiffState {
|
|||||||
self.phase = Phase::After;
|
self.phase = Phase::After;
|
||||||
self.comparison = None;
|
self.comparison = None;
|
||||||
self.comparison_id = None;
|
self.comparison_id = None;
|
||||||
|
self.review_comparison = None;
|
||||||
self.reviewed_files.clear();
|
self.reviewed_files.clear();
|
||||||
self.queue(operation);
|
self.queue(operation);
|
||||||
}
|
}
|
||||||
@@ -2021,6 +2303,27 @@ impl DiffState {
|
|||||||
.files
|
.files
|
||||||
.get(index)
|
.get(index)
|
||||||
.ok_or("No changed file is selected")?;
|
.ok_or("No changed file is selected")?;
|
||||||
|
if comparison
|
||||||
|
.revisions
|
||||||
|
.iter()
|
||||||
|
.all(|revision| !revision.is_empty())
|
||||||
|
{
|
||||||
|
if !review.initialized && review.document.root.as_os_str().is_empty() {
|
||||||
|
review.document = crate::review::Review::new(
|
||||||
|
self.review_root
|
||||||
|
.as_ref()
|
||||||
|
.unwrap_or(&comparison.root)
|
||||||
|
.clone(),
|
||||||
|
);
|
||||||
|
review.document.comparison = self.review_comparison.clone();
|
||||||
|
review.initialized = true;
|
||||||
|
}
|
||||||
|
if &review.document.root != self.review_root.as_ref().unwrap_or(&comparison.root)
|
||||||
|
|| review.document.comparison != self.review_comparison
|
||||||
|
{
|
||||||
|
return Err("Select the review for this comparison first");
|
||||||
|
}
|
||||||
|
}
|
||||||
let reviewed = !self.reviewed_files.remove(&index);
|
let reviewed = !self.reviewed_files.remove(&index);
|
||||||
if reviewed {
|
if reviewed {
|
||||||
self.reviewed_files.insert(index);
|
self.reviewed_files.insert(index);
|
||||||
@@ -2031,9 +2334,6 @@ impl DiffState {
|
|||||||
.iter()
|
.iter()
|
||||||
.all(|revision| !revision.is_empty())
|
.all(|revision| !revision.is_empty())
|
||||||
{
|
{
|
||||||
if review.document.root.as_os_str().is_empty() {
|
|
||||||
review.document = crate::review::Review::new(comparison.root.clone());
|
|
||||||
}
|
|
||||||
let records = &mut review.document.reviewed_files;
|
let records = &mut review.document.reviewed_files;
|
||||||
records.retain(|record| {
|
records.retain(|record| {
|
||||||
record.revisions
|
record.revisions
|
||||||
@@ -2068,7 +2368,9 @@ impl DiffState {
|
|||||||
let Some(comparison) = self.comparison.as_ref() else {
|
let Some(comparison) = self.comparison.as_ref() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
if review.document.root != comparison.root {
|
if &review.document.root != self.review_root.as_ref().unwrap_or(&comparison.root)
|
||||||
|
|| review.document.comparison != self.review_comparison
|
||||||
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let records: BTreeMap<_, _> = review
|
let records: BTreeMap<_, _> = review
|
||||||
@@ -2516,7 +2818,9 @@ impl DiffState {
|
|||||||
self.modified_after.insert(self.file);
|
self.modified_after.insert(self.file);
|
||||||
}
|
}
|
||||||
let comparison = self.comparison.as_ref().unwrap();
|
let comparison = self.comparison.as_ref().unwrap();
|
||||||
let record = (review.document.root == comparison.root)
|
let record = (&review.document.root
|
||||||
|
== self.review_root.as_ref().unwrap_or(&comparison.root)
|
||||||
|
&& review.document.comparison == self.review_comparison)
|
||||||
.then(|| {
|
.then(|| {
|
||||||
review.document.reviewed_files.iter().position(|record| {
|
review.document.reviewed_files.iter().position(|record| {
|
||||||
record.revisions
|
record.revisions
|
||||||
@@ -2677,6 +2981,7 @@ impl DiffState {
|
|||||||
fn rebuild_file(&mut self, reset: bool) {
|
fn rebuild_file(&mut self, reset: bool) {
|
||||||
self.normalize_focus();
|
self.normalize_focus();
|
||||||
self.rows.clear();
|
self.rows.clear();
|
||||||
|
self.conflict = None;
|
||||||
self.terminal_rows = [None; 2];
|
self.terminal_rows = [None; 2];
|
||||||
self.display_rows.clear();
|
self.display_rows.clear();
|
||||||
self.display_sides.clear();
|
self.display_sides.clear();
|
||||||
@@ -2857,7 +3162,11 @@ impl DiffState {
|
|||||||
// Unified hunks show all removals before all additions, without alignment holes.
|
// Unified hunks show all removals before all additions, without alignment holes.
|
||||||
for side in [Focus::Before, Focus::After] {
|
for side in [Focus::Before, Focus::After] {
|
||||||
for aligned in run_start..index {
|
for aligned in run_start..index {
|
||||||
if self.source_row(side, aligned).is_some() {
|
if self.source_row(side, aligned).is_some()
|
||||||
|
|| self.conflict.as_ref().is_some_and(|conflict| {
|
||||||
|
conflict.rows[aligned][usize::from(side == Focus::After)].is_some()
|
||||||
|
})
|
||||||
|
{
|
||||||
self.display_rows.push(DisplayRow::Line(aligned));
|
self.display_rows.push(DisplayRow::Line(aligned));
|
||||||
self.display_sides.push(Some(side));
|
self.display_sides.push(Some(side));
|
||||||
}
|
}
|
||||||
|
|||||||
+62
-6
@@ -1694,7 +1694,7 @@ pub struct ResolverFile {
|
|||||||
pub seed_source: usize,
|
pub seed_source: usize,
|
||||||
projection: Option<crate::resolve::Projection>,
|
projection: Option<crate::resolve::Projection>,
|
||||||
diff: DiffState,
|
diff: DiffState,
|
||||||
comparison_key: Option<(u64, usize)>,
|
comparison_key: Option<(u64, usize, Option<usize>)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ResolverFile {
|
impl ResolverFile {
|
||||||
@@ -2228,6 +2228,7 @@ pub struct Editor {
|
|||||||
bottom_bar: ChromeLine,
|
bottom_bar: ChromeLine,
|
||||||
popup: PopupConfig,
|
popup: PopupConfig,
|
||||||
labels: BTreeMap<String, String>,
|
labels: BTreeMap<String, String>,
|
||||||
|
pub(crate) cursor_config: crate::cursor::CursorConfig,
|
||||||
scroll_distance: Option<usize>,
|
scroll_distance: Option<usize>,
|
||||||
scroll_step_lines: Option<NonZeroUsize>,
|
scroll_step_lines: Option<NonZeroUsize>,
|
||||||
highlight_trailing_whitespace: bool,
|
highlight_trailing_whitespace: bool,
|
||||||
@@ -2281,6 +2282,7 @@ pub struct Editor {
|
|||||||
diff_return_buffer: Option<usize>,
|
diff_return_buffer: Option<usize>,
|
||||||
diff_context_options: (usize, usize),
|
diff_context_options: (usize, usize),
|
||||||
diff_display_options: (ViewMode, SplitAxis),
|
diff_display_options: (ViewMode, SplitAxis),
|
||||||
|
pub(crate) diff_follow_working_copy: bool,
|
||||||
pub(crate) pull_request_review_requested_symbol: String,
|
pub(crate) pull_request_review_requested_symbol: String,
|
||||||
pub(crate) pull_request_approved_symbol: String,
|
pub(crate) pull_request_approved_symbol: String,
|
||||||
pub(crate) pull_request_preferences: crate::pull_requests::Preferences,
|
pub(crate) pull_request_preferences: crate::pull_requests::Preferences,
|
||||||
@@ -2353,6 +2355,7 @@ impl Editor {
|
|||||||
bottom_bar: ChromeLine::bottom(),
|
bottom_bar: ChromeLine::bottom(),
|
||||||
popup: PopupConfig::default(),
|
popup: PopupConfig::default(),
|
||||||
labels: BTreeMap::new(),
|
labels: BTreeMap::new(),
|
||||||
|
cursor_config: crate::cursor::CursorConfig::default(),
|
||||||
scroll_distance: Some(0),
|
scroll_distance: Some(0),
|
||||||
scroll_step_lines: None,
|
scroll_step_lines: None,
|
||||||
highlight_trailing_whitespace: false,
|
highlight_trailing_whitespace: false,
|
||||||
@@ -2427,6 +2430,7 @@ impl Editor {
|
|||||||
diff_return_buffer: None,
|
diff_return_buffer: None,
|
||||||
diff_context_options: (10, 15),
|
diff_context_options: (10, 15),
|
||||||
diff_display_options: (ViewMode::Split, SplitAxis::Vertical),
|
diff_display_options: (ViewMode::Split, SplitAxis::Vertical),
|
||||||
|
diff_follow_working_copy: false,
|
||||||
pull_request_review_requested_symbol: "🙏".into(),
|
pull_request_review_requested_symbol: "🙏".into(),
|
||||||
pull_request_approved_symbol: "👍".into(),
|
pull_request_approved_symbol: "👍".into(),
|
||||||
pull_request_preferences: Default::default(),
|
pull_request_preferences: Default::default(),
|
||||||
@@ -3088,11 +3092,34 @@ impl Editor {
|
|||||||
let Some(buffer) = self.buffer(draft_buffer) else {
|
let Some(buffer) = self.buffer(draft_buffer) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let key = (buffer.text_revision, source);
|
let base_index = resolver.selected_base;
|
||||||
|
let key = (buffer.text_revision, source, base_index);
|
||||||
let changed = file.comparison_key != Some(key);
|
let changed = file.comparison_key != Some(key);
|
||||||
let first_comparison = file.diff.comparison_id.is_none();
|
let first_comparison = file.diff.comparison_id.is_none();
|
||||||
if changed {
|
if changed {
|
||||||
if file.comparison_key.is_none_or(|(_, index)| index != source) {
|
let base = match base_index {
|
||||||
|
Some(index) => match &file.projection {
|
||||||
|
Some(projection) => projection.alternative(index),
|
||||||
|
None => file
|
||||||
|
.sources
|
||||||
|
.get(index)
|
||||||
|
.map(|source| source.text.as_str())
|
||||||
|
.ok_or_else(|| "No such conflict base".into()),
|
||||||
|
},
|
||||||
|
None => Ok(""),
|
||||||
|
};
|
||||||
|
let base = match base {
|
||||||
|
Ok(text) => split_lines(text),
|
||||||
|
Err(error) => {
|
||||||
|
self.resolvers.get_mut(&self.tab_id).unwrap().files[file_index].error =
|
||||||
|
Some(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if file
|
||||||
|
.comparison_key
|
||||||
|
.is_none_or(|(_, index, _)| index != source)
|
||||||
|
{
|
||||||
let alternative = match &file.projection {
|
let alternative = match &file.projection {
|
||||||
Some(projection) => projection.alternative(source),
|
Some(projection) => projection.alternative(source),
|
||||||
None => Ok(file
|
None => Ok(file
|
||||||
@@ -3141,7 +3168,7 @@ impl Editor {
|
|||||||
.set_context_options(self.diff_context_options.0, self.diff_context_options.1);
|
.set_context_options(self.diff_context_options.0, self.diff_context_options.1);
|
||||||
let previous_id = file.diff.comparison_id;
|
let previous_id = file.diff.comparison_id;
|
||||||
file.diff
|
file.diff
|
||||||
.set_resolver_text(&draft.lines, &alternative.lines);
|
.set_resolver_text(&draft.lines, &alternative.lines, Some(&base));
|
||||||
if file.diff.comparison_id != previous_id {
|
if file.diff.comparison_id != previous_id {
|
||||||
self.next_diff_request = self
|
self.next_diff_request = self
|
||||||
.next_diff_request
|
.next_diff_request
|
||||||
@@ -4570,6 +4597,16 @@ impl Editor {
|
|||||||
.as_mut()
|
.as_mut()
|
||||||
.is_some_and(|diff| diff.apply_result(id, result));
|
.is_some_and(|diff| diff.apply_result(id, result));
|
||||||
}
|
}
|
||||||
|
let result = if let Ok(Outcome::BookmarkRefresh(comparison)) = &result
|
||||||
|
&& self.current_review_dirty()
|
||||||
|
&& self
|
||||||
|
.workspace_diff()
|
||||||
|
.is_some_and(|diff| diff.review_comparison.as_ref() != Some(&comparison.revisions))
|
||||||
|
{
|
||||||
|
Err("Save the local review before changing comparison endpoints".into())
|
||||||
|
} else {
|
||||||
|
result
|
||||||
|
};
|
||||||
self.outstanding_diff_request = None;
|
self.outstanding_diff_request = None;
|
||||||
if self.diff.as_ref().is_some_and(DiffState::refresh_pending)
|
if self.diff.as_ref().is_some_and(DiffState::refresh_pending)
|
||||||
&& !matches!(&result, Ok(Outcome::CheckoutRefresh(None)))
|
&& !matches!(&result, Ok(Outcome::CheckoutRefresh(None)))
|
||||||
@@ -4630,8 +4667,7 @@ impl Editor {
|
|||||||
view.clamp(&self.active.lines);
|
view.clamp(&self.active.lines);
|
||||||
self.active.view = view;
|
self.active.view = view;
|
||||||
}
|
}
|
||||||
if self.review_directory.is_some()
|
if self.resolver().is_none()
|
||||||
&& self.resolver().is_none()
|
|
||||||
&& self
|
&& self
|
||||||
.workspace_diff()
|
.workspace_diff()
|
||||||
.is_some_and(|diff| diff.phase == Phase::View)
|
.is_some_and(|diff| diff.phase == Phase::View)
|
||||||
@@ -4730,6 +4766,7 @@ impl Editor {
|
|||||||
diff.word_wrap = self.active.view.word_wrap;
|
diff.word_wrap = self.active.view.word_wrap;
|
||||||
diff.view_mode = self.diff_display_options.0;
|
diff.view_mode = self.diff_display_options.0;
|
||||||
diff.split_axis = self.diff_display_options.1;
|
diff.split_axis = self.diff_display_options.1;
|
||||||
|
diff.follow_working_copy = self.diff_follow_working_copy;
|
||||||
diff.set_context_options(self.diff_context_options.0, self.diff_context_options.1);
|
diff.set_context_options(self.diff_context_options.0, self.diff_context_options.1);
|
||||||
self.diff = Some(diff);
|
self.diff = Some(diff);
|
||||||
}
|
}
|
||||||
@@ -10109,6 +10146,9 @@ impl Editor {
|
|||||||
self.active.view.mode = Mode::Normal;
|
self.active.view.mode = Mode::Normal;
|
||||||
}
|
}
|
||||||
if entry.action == Action::DiffModeToggle {
|
if entry.action == Action::DiffModeToggle {
|
||||||
|
if self.local_review_close_blocked() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
let Some(diff) = self.diff.as_mut() else {
|
let Some(diff) = self.diff.as_mut() else {
|
||||||
self.set_message("No comparison is open");
|
self.set_message("No comparison is open");
|
||||||
return None;
|
return None;
|
||||||
@@ -10122,6 +10162,19 @@ impl Editor {
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
if entry.action == Action::DiffReviewToggle {
|
if entry.action == Action::DiffReviewToggle {
|
||||||
|
if self
|
||||||
|
.picker_diff()
|
||||||
|
.is_none_or(|diff| diff.phase != Phase::View)
|
||||||
|
{
|
||||||
|
self.set_message("No comparison is open");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if self.resolver().is_none()
|
||||||
|
&& let Err(error) = self.initialize_local_review()
|
||||||
|
{
|
||||||
|
self.set_message(format!("Local review: {error}"));
|
||||||
|
return None;
|
||||||
|
}
|
||||||
let mut review = std::mem::take(&mut self.local_review);
|
let mut review = std::mem::take(&mut self.local_review);
|
||||||
let result = self
|
let result = self
|
||||||
.picker_diff_mut()
|
.picker_diff_mut()
|
||||||
@@ -10231,6 +10284,9 @@ impl Editor {
|
|||||||
self.active.view.command_line.clear();
|
self.active.view.command_line.clear();
|
||||||
self.active.view.mode = Mode::Normal;
|
self.active.view.mode = Mode::Normal;
|
||||||
}
|
}
|
||||||
|
if let Err(error) = self.initialize_local_review() {
|
||||||
|
self.set_message(format!("Local review: {error}"));
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
self.set_message("The diff.close command takes no arguments");
|
self.set_message("The diff.close command takes no arguments");
|
||||||
}
|
}
|
||||||
|
|||||||
+70
-20
@@ -34,12 +34,15 @@ impl Editor {
|
|||||||
|
|
||||||
pub(super) fn initialize_local_review(&mut self) -> Result<(), String> {
|
pub(super) fn initialize_local_review(&mut self) -> Result<(), String> {
|
||||||
let root = self.review_root()?;
|
let root = self.review_root()?;
|
||||||
if self.local_review.document.root == root {
|
let comparison = self.review_comparison();
|
||||||
|
if self.local_review.document.root == root
|
||||||
|
&& self.local_review.document.comparison == comparison
|
||||||
|
{
|
||||||
self.local_review.initialized = true;
|
self.local_review.initialized = true;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
if self.current_review_dirty() {
|
if self.current_review_dirty() {
|
||||||
return Err("Save the current review before changing repositories".into());
|
return Err("Save the current review before changing review scopes".into());
|
||||||
}
|
}
|
||||||
let reviews = match self.review_directory.as_deref() {
|
let reviews = match self.review_directory.as_deref() {
|
||||||
Some(directory) => crate::review::Store::open_read_only(directory)?
|
Some(directory) => crate::review::Store::open_read_only(directory)?
|
||||||
@@ -49,12 +52,17 @@ impl Editor {
|
|||||||
None => Vec::new(),
|
None => Vec::new(),
|
||||||
};
|
};
|
||||||
self.detach_review_buffers();
|
self.detach_review_buffers();
|
||||||
|
let mut document = Review::new(root);
|
||||||
|
document.comparison = comparison.clone();
|
||||||
self.local_review = crate::review::ReviewState {
|
self.local_review = crate::review::ReviewState {
|
||||||
document: Review::new(root),
|
document,
|
||||||
initialized: true,
|
initialized: true,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let mut reviews = reviews;
|
let mut reviews: Vec<_> = reviews
|
||||||
|
.into_iter()
|
||||||
|
.filter(|review| review.comparison == comparison)
|
||||||
|
.collect();
|
||||||
if reviews.len() == 1 {
|
if reviews.len() == 1 {
|
||||||
self.local_review.document = reviews.pop().unwrap();
|
self.local_review.document = reviews.pop().unwrap();
|
||||||
} else if reviews.len() > 1 {
|
} else if reviews.len() > 1 {
|
||||||
@@ -63,8 +71,30 @@ impl Editor {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn review_comparison(&self) -> Option<[String; 2]> {
|
||||||
|
if self.active.review_buffer.is_some() {
|
||||||
|
return self.local_review.document.comparison.clone();
|
||||||
|
}
|
||||||
|
self.diff
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|diff| diff.review_comparison.clone())
|
||||||
|
}
|
||||||
|
|
||||||
fn review_root(&self) -> Result<PathBuf, String> {
|
fn review_root(&self) -> Result<PathBuf, String> {
|
||||||
if let Some(diff) = self.workspace_diff() {
|
if self
|
||||||
|
.active
|
||||||
|
.review_buffer
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|binding| binding.owner != self.tab_id)
|
||||||
|
{
|
||||||
|
return Err("Focus a source or comparison pane in this tab to select a review".into());
|
||||||
|
}
|
||||||
|
if self.active.review_buffer.is_some()
|
||||||
|
|| (self.active.path.is_none() && self.local_review.initialized && self.diff.is_none())
|
||||||
|
{
|
||||||
|
return Ok(self.local_review.document.root.clone());
|
||||||
|
}
|
||||||
|
if let Some(diff) = self.diff.as_ref() {
|
||||||
return diff
|
return diff
|
||||||
.comparison
|
.comparison
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -73,18 +103,16 @@ impl Editor {
|
|||||||
})
|
})
|
||||||
.ok_or_else(|| "Comparison is still loading".into());
|
.ok_or_else(|| "Comparison is still loading".into());
|
||||||
}
|
}
|
||||||
if self.active.review_buffer.is_some()
|
|
||||||
|| (self.active.path.is_none() && self.local_review.initialized)
|
|
||||||
{
|
|
||||||
return Ok(self.local_review.document.root.clone());
|
|
||||||
}
|
|
||||||
let path = self
|
let path = self
|
||||||
.active
|
.active
|
||||||
.identity
|
.identity
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.or(self.active.path.as_deref())
|
.or(self.active.path.as_deref())
|
||||||
.ok_or("Save the source file before commenting")?;
|
.ok_or("Save the source file before commenting")?;
|
||||||
crate::repository::discover_root(path.parent().ok_or("Source has no parent directory")?)
|
let root = crate::repository::discover_root(
|
||||||
|
path.parent().ok_or("Source has no parent directory")?,
|
||||||
|
)?;
|
||||||
|
Ok(fs::canonicalize(&root).unwrap_or(root))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn open_review_selector(&mut self) -> Result<(), String> {
|
fn open_review_selector(&mut self) -> Result<(), String> {
|
||||||
@@ -93,7 +121,12 @@ impl Editor {
|
|||||||
.review_directory
|
.review_directory
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.ok_or("Review storage is not configured")?;
|
.ok_or("Review storage is not configured")?;
|
||||||
let reviews = crate::review::Store::open(directory)?.list(&root)?;
|
let comparison = self.review_comparison();
|
||||||
|
let reviews = crate::review::Store::open(directory)?
|
||||||
|
.list(&root)?
|
||||||
|
.into_iter()
|
||||||
|
.filter(|review| review.comparison == comparison)
|
||||||
|
.collect();
|
||||||
self.show_review_selector(reviews)
|
self.show_review_selector(reviews)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,6 +274,10 @@ impl Editor {
|
|||||||
root: PathBuf,
|
root: PathBuf,
|
||||||
source: ReviewSource<'_>,
|
source: ReviewSource<'_>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
|
if self.current_review_dirty() {
|
||||||
|
return Err("Save the current local review before opening another".into());
|
||||||
|
}
|
||||||
|
let comparison = self.review_comparison();
|
||||||
let (document, path, dirty) = match source {
|
let (document, path, dirty) = match source {
|
||||||
ReviewSource::Stored(id) => {
|
ReviewSource::Stored(id) => {
|
||||||
let directory = self
|
let directory = self
|
||||||
@@ -258,8 +295,15 @@ impl Editor {
|
|||||||
Some(path.to_path_buf()),
|
Some(path.to_path_buf()),
|
||||||
self.review_directory.is_some(),
|
self.review_directory.is_some(),
|
||||||
),
|
),
|
||||||
ReviewSource::New => (Review::new(root), None, true),
|
ReviewSource::New => {
|
||||||
|
let mut document = Review::new(root);
|
||||||
|
document.comparison = comparison.clone();
|
||||||
|
(document, None, true)
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
if document.comparison != comparison {
|
||||||
|
return Err("Review belongs to a different comparison or source scope".into());
|
||||||
|
}
|
||||||
// Retire old drafts before comment IDs can be reused.
|
// Retire old drafts before comment IDs can be reused.
|
||||||
self.detach_review_buffers();
|
self.detach_review_buffers();
|
||||||
let state = &mut self.local_review;
|
let state = &mut self.local_review;
|
||||||
@@ -431,9 +475,6 @@ impl Editor {
|
|||||||
{
|
{
|
||||||
return Err("This review command takes no arguments".into());
|
return Err("This review command takes no arguments".into());
|
||||||
}
|
}
|
||||||
if self.local_review.document.root.as_os_str().is_empty() {
|
|
||||||
self.local_review.document = Review::new(root.clone());
|
|
||||||
}
|
|
||||||
if !matches!(action, Action::ReviewSave | Action::ReviewCopy) {
|
if !matches!(action, Action::ReviewSave | Action::ReviewCopy) {
|
||||||
self.autosave_local_review()?;
|
self.autosave_local_review()?;
|
||||||
}
|
}
|
||||||
@@ -602,7 +643,10 @@ impl Editor {
|
|||||||
&self,
|
&self,
|
||||||
anchor: &Anchor,
|
anchor: &Anchor,
|
||||||
) -> Option<std::ops::Range<TextPosition>> {
|
) -> Option<std::ops::Range<TextPosition>> {
|
||||||
if self.active.review_buffer.is_some() || !anchor.revision.starts_with("working:") {
|
if self.local_review.document.comparison.is_some()
|
||||||
|
|| self.active.review_buffer.is_some()
|
||||||
|
|| !anchor.revision.starts_with("working:")
|
||||||
|
{
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let path = self
|
let path = self
|
||||||
@@ -621,7 +665,9 @@ impl Editor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn review_anchor_range(&self, anchor: &Anchor) -> Option<std::ops::Range<TextPosition>> {
|
fn review_anchor_range(&self, anchor: &Anchor) -> Option<std::ops::Range<TextPosition>> {
|
||||||
if let Some(diff) = self.workspace_diff() {
|
if self.local_review.document.comparison.is_some()
|
||||||
|
&& let Some(diff) = self.workspace_diff()
|
||||||
|
{
|
||||||
return diff.review_anchor_range(anchor, &self.local_review);
|
return diff.review_anchor_range(anchor, &self.local_review);
|
||||||
}
|
}
|
||||||
let path = self.local_review.document.root.join(&anchor.path);
|
let path = self.local_review.document.root.join(&anchor.path);
|
||||||
@@ -642,7 +688,9 @@ impl Editor {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
fn add_review_comment(&mut self) -> Result<(), String> {
|
fn add_review_comment(&mut self) -> Result<(), String> {
|
||||||
if let Some(owner) = self.diff_owner {
|
if self.local_review.document.comparison.is_some()
|
||||||
|
&& let Some(owner) = self.diff_owner
|
||||||
|
{
|
||||||
if self.pane_id != owner {
|
if self.pane_id != owner {
|
||||||
self.select_pane(owner);
|
self.select_pane(owner);
|
||||||
}
|
}
|
||||||
@@ -801,7 +849,9 @@ impl Editor {
|
|||||||
let anchor = anchor.clone();
|
let anchor = anchor.clone();
|
||||||
let mut stale = !current;
|
let mut stale = !current;
|
||||||
self.local_review.selected = Some(id);
|
self.local_review.selected = Some(id);
|
||||||
if let Some(owner) = self.diff_owner {
|
if self.local_review.document.comparison.is_some()
|
||||||
|
&& let Some(owner) = self.diff_owner
|
||||||
|
{
|
||||||
let target = self
|
let target = self
|
||||||
.workspace_diff()
|
.workspace_diff()
|
||||||
.and_then(|diff| diff.comparison.as_ref())
|
.and_then(|diff| diff.comparison.as_ref())
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ mod battery;
|
|||||||
mod blame;
|
mod blame;
|
||||||
pub mod chrome;
|
pub mod chrome;
|
||||||
pub mod clipboard;
|
pub mod clipboard;
|
||||||
|
pub(crate) mod cursor;
|
||||||
pub mod diff;
|
pub mod diff;
|
||||||
pub mod editor;
|
pub mod editor;
|
||||||
pub mod files;
|
pub mod files;
|
||||||
|
|||||||
+4
-2
@@ -85,7 +85,7 @@ Restart: :restart closes clean buffers; unsaved buffers remain open and block re
|
|||||||
Tabs: :tab.new, :tab.previous, :tab.next, :tab.close, :tab.force_close
|
Tabs: :tab.new, :tab.previous, :tab.next, :tab.close, :tab.force_close
|
||||||
Tabs retain pane layouts and independent views; close protects exclusively displayed unsaved edits
|
Tabs retain pane layouts and independent views; close protects exclusively displayed unsaved edits
|
||||||
Markdown links: :follow_link (primary caret; local files only)
|
Markdown links: :follow_link (primary caret; local files only)
|
||||||
Diff: :diff (Normal); choose Before then After; Working checkout After edits live unsaved buffers
|
Diff: :diff (Normal); floating revision/bookmark lists; choose Before then After; Before marked inline
|
||||||
Before and historical/remote After stay read-only; Tab/Shift+Tab categories/Normal focus
|
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
|
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
|
Switching Before/After preserves the aligned cursor row and display column
|
||||||
@@ -93,12 +93,14 @@ Diff: :diff (Normal); choose Before then After; Working checkout After edits liv
|
|||||||
Normal [/] explicitly expands context; Insert brackets remain literal text
|
Normal [/] explicitly expands context; Insert brackets remain literal text
|
||||||
Normal Space v cycles split/unified; Space s flips left/right vs above/below split
|
Normal Space v cycles split/unified; Space s flips left/right vs above/below split
|
||||||
Defaults: config.diff_view_mode = \"split\"; config.diff_split_orientation = \"vertical\"
|
Defaults: config.diff_view_mode = \"split\"; config.diff_split_orientation = \"vertical\"
|
||||||
Working After follows descendant checkouts; config.diff_follow_working_copy = false disables it
|
Follow checkout After edits live buffers and follows descendants; config.diff_follow_working_copy = false disables following
|
||||||
Esc closes Before/After selectors; :diff.close exits an opened comparison explicitly
|
Esc closes Before/After selectors; :diff.close exits an opened comparison explicitly
|
||||||
Remote refs/history load into temporary comparison repos without changing active refs/checkout
|
Remote refs/history load into temporary comparison repos without changing active refs/checkout
|
||||||
Changed files: normal picker/explorer bindings, Ctrl+w close; A/M/D/R status and rename paths
|
Changed files: normal picker/explorer bindings, Ctrl+w close; A/M/D/R status and rename paths
|
||||||
Local reviews: :comment anchors exact Visual text, Visual Line rows, or the current line
|
Local reviews: :comment anchors exact Visual text, Visual Line rows, or the current line
|
||||||
Works in ordinary repository source buffers and diff text panes
|
Works in ordinary repository source buffers and diff text panes
|
||||||
|
Different resolved Before/After pairs select separate reviews; reopening a pair resumes its review
|
||||||
|
Working refreshes retain the active review; ordinary source reviews stay separate
|
||||||
Selected comments highlight their precise text
|
Selected comments highlight their precise text
|
||||||
:review.edit/delete [ID], :review.next/previous, :review.overview
|
:review.edit/delete [ID], :review.next/previous, :review.overview
|
||||||
:review.summary; :review.verdict looks_good|changes_requested|none
|
:review.summary; :review.verdict looks_good|changes_requested|none
|
||||||
|
|||||||
+108
-12
@@ -1,7 +1,8 @@
|
|||||||
//! Read-only active-repository discovery and isolated revision comparison.
|
//! Read-only active-repository discovery and isolated revision comparison.
|
||||||
use crate::diff::{
|
use crate::diff::{
|
||||||
BlameRevision, Catalog, Category, ChangedFile, Comparison, ComparisonMode, FileStatus,
|
BlameRevision, Catalog, Category, ChangedFile, Comparison, ComparisonMode, FileStatus,
|
||||||
PullRequestMetadata, PullRequestPage, RepoKind, Revision, RevisionTarget, line_hunks,
|
PullRequestMetadata, PullRequestPage, RepoKind, Revision, RevisionGraph, RevisionTarget,
|
||||||
|
line_hunks,
|
||||||
};
|
};
|
||||||
use std::{
|
use std::{
|
||||||
collections::{BTreeMap, BTreeSet},
|
collections::{BTreeMap, BTreeSet},
|
||||||
@@ -1180,10 +1181,25 @@ impl Repository {
|
|||||||
root: &Path,
|
root: &Path,
|
||||||
store: &Path,
|
store: &Path,
|
||||||
entries: &mut Vec<Revision>,
|
entries: &mut Vec<Revision>,
|
||||||
|
graph: &mut BTreeMap<String, RevisionGraph>,
|
||||||
notices: &mut Vec<String>,
|
notices: &mut Vec<String>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
if self.kind == RepoKind::Jujutsu {
|
if self.kind == RepoKind::Jujutsu {
|
||||||
let template = r#""[" ++ json(commit_id) ++ "," ++ json(change_id) ++ "," ++ json(description) ++ "," ++ json(local_bookmarks.map(|b| b.name())) ++ "," ++ json(remote_bookmarks.map(|b| b.name())) ++ "," ++ json(remote_bookmarks.map(|b| b.remote())) ++ "]\n""#;
|
#[derive(serde::Deserialize)]
|
||||||
|
struct JjRevision(
|
||||||
|
String,
|
||||||
|
String,
|
||||||
|
String,
|
||||||
|
Vec<String>,
|
||||||
|
Vec<String>,
|
||||||
|
Vec<String>,
|
||||||
|
Vec<String>,
|
||||||
|
String,
|
||||||
|
String,
|
||||||
|
bool,
|
||||||
|
bool,
|
||||||
|
);
|
||||||
|
let template = r#""[" ++ json(commit_id) ++ "," ++ json(change_id) ++ "," ++ json(description) ++ "," ++ json(local_bookmarks.map(|b| b.name())) ++ "," ++ json(remote_bookmarks.map(|b| b.name())) ++ "," ++ json(remote_bookmarks.map(|b| b.remote())) ++ "," ++ json(parents.map(|p| p.commit_id())) ++ "," ++ json(author.name()) ++ "," ++ json(author.timestamp().format("%Y-%m-%d %H:%M")) ++ "," ++ json(current_working_copy) ++ "," ++ json(immutable) ++ "]\n""#;
|
||||||
let revset = if root == self.root {
|
let revset = if root == self.root {
|
||||||
"all()"
|
"all()"
|
||||||
} else {
|
} else {
|
||||||
@@ -1192,15 +1208,40 @@ impl Repository {
|
|||||||
let output =
|
let output =
|
||||||
text(self.jj(root, &["log", "--no-graph", "-r", revset, "-T", template])?)?;
|
text(self.jj(root, &["log", "--no-graph", "-r", revset, "-T", template])?)?;
|
||||||
for line in output.lines() {
|
for line in output.lines() {
|
||||||
let (id, change, description, bookmarks, remote_names, remotes): (
|
let JjRevision(
|
||||||
String,
|
id,
|
||||||
String,
|
change,
|
||||||
String,
|
description,
|
||||||
Vec<String>,
|
bookmarks,
|
||||||
Vec<String>,
|
remote_names,
|
||||||
Vec<String>,
|
remotes,
|
||||||
|
parents,
|
||||||
|
author,
|
||||||
|
timestamp,
|
||||||
|
current,
|
||||||
|
immutable,
|
||||||
) = serde_json::from_str(line)
|
) = serde_json::from_str(line)
|
||||||
.map_err(|e| format!("Cannot decode Jujutsu revision: {e}"))?;
|
.map_err(|e| format!("Cannot decode Jujutsu revision: {e}"))?;
|
||||||
|
graph.entry(id.clone()).or_insert_with(|| RevisionGraph {
|
||||||
|
change_id: change.clone(),
|
||||||
|
description: description.trim_end_matches('\n').into(),
|
||||||
|
parents,
|
||||||
|
author,
|
||||||
|
timestamp,
|
||||||
|
bookmarks: bookmarks
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.chain(
|
||||||
|
remote_names
|
||||||
|
.iter()
|
||||||
|
.zip(&remotes)
|
||||||
|
.map(|(name, remote)| format!("{name}@{remote}")),
|
||||||
|
)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" "),
|
||||||
|
current: current && root == self.root,
|
||||||
|
immutable,
|
||||||
|
});
|
||||||
let target = RevisionTarget::Commit(id.clone());
|
let target = RevisionTarget::Commit(id.clone());
|
||||||
entries.push(Revision {
|
entries.push(Revision {
|
||||||
target: target.clone(),
|
target: target.clone(),
|
||||||
@@ -1370,15 +1411,69 @@ impl Repository {
|
|||||||
};
|
};
|
||||||
let mut entries = vec![Revision {
|
let mut entries = vec![Revision {
|
||||||
target: RevisionTarget::Working,
|
target: RevisionTarget::Working,
|
||||||
label: "Working checkout".into(),
|
label: "Checkout snapshot".into(),
|
||||||
description:
|
description:
|
||||||
"On-disk tracked and eligible untracked files; unsaved buffers are excluded".into(),
|
"On-disk tracked and eligible untracked files; unsaved buffers are excluded".into(),
|
||||||
category,
|
category,
|
||||||
}];
|
}];
|
||||||
let mut notices = vec![];
|
let mut notices = vec![];
|
||||||
self.revisions(&self.root, &self.store, &mut entries, &mut notices)?;
|
let mut graph = BTreeMap::new();
|
||||||
|
self.revisions(
|
||||||
|
&self.root,
|
||||||
|
&self.store,
|
||||||
|
&mut entries,
|
||||||
|
&mut graph,
|
||||||
|
&mut notices,
|
||||||
|
)?;
|
||||||
if let Some((root, store)) = &self.private {
|
if let Some((root, store)) = &self.private {
|
||||||
self.revisions(root, store, &mut entries, &mut notices)?;
|
self.revisions(root, store, &mut entries, &mut graph, &mut notices)?;
|
||||||
|
}
|
||||||
|
if self.kind == RepoKind::Jujutsu && self.private.is_some() {
|
||||||
|
let order: BTreeMap<_, _> = entries
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter_map(|(index, entry)| match &entry.target {
|
||||||
|
RevisionTarget::Commit(id) => Some((id.as_str(), index)),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let mut children: BTreeMap<_, usize> =
|
||||||
|
graph.keys().map(|id| (id.as_str(), 0)).collect();
|
||||||
|
for node in graph.values() {
|
||||||
|
for parent in &node.parents {
|
||||||
|
if let Some(count) = children.get_mut(parent.as_str()) {
|
||||||
|
*count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut ready: BTreeSet<_> = children
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, count)| **count == 0)
|
||||||
|
.map(|(&id, _)| (order[id], id))
|
||||||
|
.collect();
|
||||||
|
let mut ranks = BTreeMap::new();
|
||||||
|
while let Some((_, id)) = ready.pop_first() {
|
||||||
|
ranks.insert(id, ranks.len() + 1);
|
||||||
|
for parent in &graph[id].parents {
|
||||||
|
if let Some(count) = children.get_mut(parent.as_str()) {
|
||||||
|
*count -= 1;
|
||||||
|
if *count == 0 {
|
||||||
|
ready.insert((order[parent.as_str()], parent.as_str()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entries.sort_by_key(|entry| match &entry.target {
|
||||||
|
RevisionTarget::Working => 0,
|
||||||
|
RevisionTarget::Commit(id) => ranks.get(id.as_str()).copied().unwrap_or(usize::MAX),
|
||||||
|
RevisionTarget::Bookmark { .. } => entry
|
||||||
|
.description
|
||||||
|
.split_whitespace()
|
||||||
|
.nth(1)
|
||||||
|
.and_then(|id| ranks.get(id).copied())
|
||||||
|
.unwrap_or(usize::MAX),
|
||||||
|
_ => usize::MAX,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
for remote in &self.remotes {
|
for remote in &self.remotes {
|
||||||
for &view in self.kind.categories() {
|
for &view in self.kind.categories() {
|
||||||
@@ -1439,6 +1534,7 @@ impl Repository {
|
|||||||
Ok(Catalog {
|
Ok(Catalog {
|
||||||
kind: self.kind,
|
kind: self.kind,
|
||||||
entries,
|
entries,
|
||||||
|
graph,
|
||||||
notice: if notices.is_empty() {
|
notice: if notices.is_empty() {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+88
-20
@@ -195,6 +195,8 @@ pub struct ReviewedFile {
|
|||||||
pub struct Review {
|
pub struct Review {
|
||||||
pub version: u32,
|
pub version: u32,
|
||||||
pub root: PathBuf,
|
pub root: PathBuf,
|
||||||
|
/// Ordered frozen comparison endpoints; `None` is a source review.
|
||||||
|
pub comparison: Option<[String; 2]>,
|
||||||
pub summary: String,
|
pub summary: String,
|
||||||
pub verdict: Option<Verdict>,
|
pub verdict: Option<Verdict>,
|
||||||
pub comments: Vec<Comment>,
|
pub comments: Vec<Comment>,
|
||||||
@@ -231,8 +233,9 @@ impl Review {
|
|||||||
fs::canonicalize(&root).unwrap_or(root)
|
fs::canonicalize(&root).unwrap_or(root)
|
||||||
};
|
};
|
||||||
Self {
|
Self {
|
||||||
version: 4,
|
version: 5,
|
||||||
root,
|
root,
|
||||||
|
comparison: None,
|
||||||
summary: String::new(),
|
summary: String::new(),
|
||||||
verdict: None,
|
verdict: None,
|
||||||
comments: Vec::new(),
|
comments: Vec::new(),
|
||||||
@@ -249,12 +252,20 @@ impl Review {
|
|||||||
.iter()
|
.iter()
|
||||||
.filter(|comment| comment.resolved)
|
.filter(|comment| comment.resolved)
|
||||||
.count();
|
.count();
|
||||||
|
let (scope, before, arrow, after) = match &self.comparison {
|
||||||
|
Some([before, after]) => ("comparison ", before.as_str(), " → ", after.as_str()),
|
||||||
|
None => ("source", "", "", ""),
|
||||||
|
};
|
||||||
format!(
|
format!(
|
||||||
"Review ID: {}{}{}\n Verdict: {}; {} unresolved, {} resolved ({} total)\n",
|
"Review ID: {}{}{}\n Scope: {}{}{}{}\n Verdict: {}; {} unresolved, {} resolved ({} total)\n",
|
||||||
self.storage_id
|
self.storage_id
|
||||||
.map_or_else(|| "null".into(), |id| id.to_string()),
|
.map_or_else(|| "null".into(), |id| id.to_string()),
|
||||||
if self.summary.is_empty() { "" } else { ": " },
|
if self.summary.is_empty() { "" } else { ": " },
|
||||||
self.summary,
|
self.summary,
|
||||||
|
scope,
|
||||||
|
before,
|
||||||
|
arrow,
|
||||||
|
after,
|
||||||
match self.verdict {
|
match self.verdict {
|
||||||
Some(Verdict::LooksGood) => "looks_good",
|
Some(Verdict::LooksGood) => "looks_good",
|
||||||
Some(Verdict::ChangesRequested) => "changes_requested",
|
Some(Verdict::ChangesRequested) => "changes_requested",
|
||||||
@@ -279,9 +290,24 @@ impl Review {
|
|||||||
.filter(|comment| all || !comment.resolved)
|
.filter(|comment| all || !comment.resolved)
|
||||||
{
|
{
|
||||||
let anchor = &comment.anchor;
|
let anchor = &comment.anchor;
|
||||||
|
let excerpt = anchor.excerpt();
|
||||||
|
let language =
|
||||||
|
match crate::syntax::language_id(Some(&anchor.path), anchor.content.lines().next())
|
||||||
|
{
|
||||||
|
Some("honey") => "hon",
|
||||||
|
language => language.unwrap_or(""),
|
||||||
|
};
|
||||||
|
let fence_width = 3.max(
|
||||||
|
excerpt
|
||||||
|
.split(|ch| ch != '`')
|
||||||
|
.map(str::len)
|
||||||
|
.max()
|
||||||
|
.unwrap_or(0)
|
||||||
|
+ 1,
|
||||||
|
);
|
||||||
writeln!(
|
writeln!(
|
||||||
text,
|
text,
|
||||||
"\nComment {} [{}] {} ({}) {}:{}–{}:{} @ {}\n{}\n{}",
|
"\nComment {} [{}] {} ({}) {}:{}–{}:{} @ {}",
|
||||||
comment.id,
|
comment.id,
|
||||||
if comment.resolved {
|
if comment.resolved {
|
||||||
"resolved"
|
"resolved"
|
||||||
@@ -298,21 +324,31 @@ impl Review {
|
|||||||
anchor.end.row,
|
anchor.end.row,
|
||||||
anchor.end.column,
|
anchor.end.column,
|
||||||
anchor.revision,
|
anchor.revision,
|
||||||
anchor.excerpt(),
|
|
||||||
comment.body,
|
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
write!(text, "{:`<fence_width$}{language}\n{excerpt}", "").unwrap();
|
||||||
|
if !excerpt.ends_with('\n') {
|
||||||
|
text.push('\n');
|
||||||
|
}
|
||||||
|
writeln!(text, "{:`<fence_width$}\n{}", "", comment.body).unwrap();
|
||||||
}
|
}
|
||||||
text
|
text
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate(&self) -> Result<(), String> {
|
fn validate(&self) -> Result<(), String> {
|
||||||
if self.version != 4 {
|
if self.version != 5 {
|
||||||
return Err(format!("Unsupported review version {}", self.version));
|
return Err(format!("Unsupported review version {}", self.version));
|
||||||
}
|
}
|
||||||
if !self.root.is_absolute() || !self.root.is_dir() {
|
if !self.root.is_absolute() || !self.root.is_dir() {
|
||||||
return Err("Review repository root must be an existing absolute directory".into());
|
return Err("Review repository root must be an existing absolute directory".into());
|
||||||
}
|
}
|
||||||
|
if let Some(revisions) = &self.comparison
|
||||||
|
&& revisions
|
||||||
|
.iter()
|
||||||
|
.any(|revision| revision.is_empty() || revision.chars().any(char::is_control))
|
||||||
|
{
|
||||||
|
return Err("Review comparison endpoints require immutable revision identities".into());
|
||||||
|
}
|
||||||
let mut ids = BTreeSet::new();
|
let mut ids = BTreeSet::new();
|
||||||
for comment in &self.comments {
|
for comment in &self.comments {
|
||||||
if comment.id == 0 || !ids.insert(comment.id) {
|
if comment.id == 0 || !ids.insert(comment.id) {
|
||||||
@@ -374,7 +410,7 @@ impl Review {
|
|||||||
}
|
}
|
||||||
let header: Header = serde_json::from_slice(bytes)
|
let header: Header = serde_json::from_slice(bytes)
|
||||||
.map_err(|error| format!("Invalid review JSON: {error}"))?;
|
.map_err(|error| format!("Invalid review JSON: {error}"))?;
|
||||||
if header.version != 4 {
|
if header.version != 5 {
|
||||||
return Err(format!("Unsupported review version {}", header.version));
|
return Err(format!("Unsupported review version {}", header.version));
|
||||||
}
|
}
|
||||||
serde_json::from_slice(bytes).map_err(|error| format!("Invalid review JSON: {error}"))
|
serde_json::from_slice(bytes).map_err(|error| format!("Invalid review JSON: {error}"))
|
||||||
@@ -477,7 +513,14 @@ const REVIEWS_SCHEMA: &str = "CREATE TABLE reviews (
|
|||||||
root TEXT NOT NULL,
|
root TEXT NOT NULL,
|
||||||
revision INTEGER NOT NULL CHECK (revision > 0),
|
revision INTEGER NOT NULL CHECK (revision > 0),
|
||||||
summary TEXT NOT NULL,
|
summary TEXT NOT NULL,
|
||||||
verdict TEXT CHECK (verdict IN ('looks_good', 'changes_requested'))
|
verdict TEXT CHECK (verdict IN ('looks_good', 'changes_requested')),
|
||||||
|
before_revision TEXT,
|
||||||
|
after_revision TEXT,
|
||||||
|
CHECK (
|
||||||
|
(before_revision IS NULL AND after_revision IS NULL)
|
||||||
|
OR (before_revision IS NOT NULL AND after_revision IS NOT NULL
|
||||||
|
AND length(before_revision) > 0 AND length(after_revision) > 0)
|
||||||
|
)
|
||||||
) STRICT";
|
) STRICT";
|
||||||
|
|
||||||
// SQLite integers are signed; decimal text preserves the model's full u64 comment IDs.
|
// SQLite integers are signed; decimal text preserves the model's full u64 comment IDs.
|
||||||
@@ -560,7 +603,7 @@ impl Store {
|
|||||||
.map_err(database_error)?
|
.map_err(database_error)?
|
||||||
.collect::<Result<_, _>>()
|
.collect::<Result<_, _>>()
|
||||||
.map_err(database_error)?;
|
.map_err(database_error)?;
|
||||||
if version != 3 || schema != [COMMENTS_SCHEMA, REVIEWED_FILES_SCHEMA, REVIEWS_SCHEMA] {
|
if version != 4 || schema != [COMMENTS_SCHEMA, REVIEWED_FILES_SCHEMA, REVIEWS_SCHEMA] {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"Unsupported review database schema (version {version}); existing data was not changed"
|
"Unsupported review database schema (version {version}); existing data was not changed"
|
||||||
));
|
));
|
||||||
@@ -628,7 +671,7 @@ impl Store {
|
|||||||
.execute_batch(REVIEWED_FILES_SCHEMA)
|
.execute_batch(REVIEWED_FILES_SCHEMA)
|
||||||
.map_err(database_error)?;
|
.map_err(database_error)?;
|
||||||
transaction
|
transaction
|
||||||
.pragma_update(None, "user_version", 3)
|
.pragma_update(None, "user_version", 4)
|
||||||
.map_err(database_error)?;
|
.map_err(database_error)?;
|
||||||
transaction.commit().map_err(database_error)?;
|
transaction.commit().map_err(database_error)?;
|
||||||
} else {
|
} else {
|
||||||
@@ -655,7 +698,7 @@ impl Store {
|
|||||||
let reviews = {
|
let reviews = {
|
||||||
let mut statement = transaction
|
let mut statement = transaction
|
||||||
.prepare(
|
.prepare(
|
||||||
"SELECT id, root, revision, summary, verdict FROM reviews WHERE root = ?1 ORDER BY id DESC",
|
"SELECT id, root, revision, summary, verdict, before_revision, after_revision FROM reviews WHERE root = ?1 ORDER BY id DESC",
|
||||||
)
|
)
|
||||||
.map_err(database_error)?;
|
.map_err(database_error)?;
|
||||||
let mut rows = statement.query([root_text]).map_err(database_error)?;
|
let mut rows = statement.query([root_text]).map_err(database_error)?;
|
||||||
@@ -680,7 +723,7 @@ impl Store {
|
|||||||
.map_err(database_error)?;
|
.map_err(database_error)?;
|
||||||
let review = {
|
let review = {
|
||||||
let mut statement = transaction
|
let mut statement = transaction
|
||||||
.prepare("SELECT id, root, revision, summary, verdict FROM reviews WHERE id = ?1 AND root = ?2")
|
.prepare("SELECT id, root, revision, summary, verdict, before_revision, after_revision FROM reviews WHERE id = ?1 AND root = ?2")
|
||||||
.map_err(database_error)?;
|
.map_err(database_error)?;
|
||||||
let mut rows = statement
|
let mut rows = statement
|
||||||
.query(params![id, root_text])
|
.query(params![id, root_text])
|
||||||
@@ -716,6 +759,9 @@ impl Store {
|
|||||||
_ => return Err("Invalid review storage identity".into()),
|
_ => return Err("Invalid review storage identity".into()),
|
||||||
};
|
};
|
||||||
let mut size = review.root.as_os_str().len() + review.summary.len();
|
let mut size = review.root.as_os_str().len() + review.summary.len();
|
||||||
|
if let Some(revisions) = &review.comparison {
|
||||||
|
size += revisions[0].len() + revisions[1].len();
|
||||||
|
}
|
||||||
for comment in &review.comments {
|
for comment in &review.comments {
|
||||||
size = size
|
size = size
|
||||||
.checked_add(
|
.checked_add(
|
||||||
@@ -750,26 +796,34 @@ impl Store {
|
|||||||
Some(Verdict::ChangesRequested) => Some("changes_requested"),
|
Some(Verdict::ChangesRequested) => Some("changes_requested"),
|
||||||
None => None,
|
None => None,
|
||||||
};
|
};
|
||||||
|
let before = review
|
||||||
|
.comparison
|
||||||
|
.as_ref()
|
||||||
|
.map(|revisions| revisions[0].as_str());
|
||||||
|
let after = review
|
||||||
|
.comparison
|
||||||
|
.as_ref()
|
||||||
|
.map(|revisions| revisions[1].as_str());
|
||||||
let transaction = self
|
let transaction = self
|
||||||
.connection
|
.connection
|
||||||
.transaction_with_behavior(TransactionBehavior::Immediate)
|
.transaction_with_behavior(TransactionBehavior::Immediate)
|
||||||
.map_err(database_error)?;
|
.map_err(database_error)?;
|
||||||
let id = if let Some(id) = review.storage_id {
|
let id = if let Some(id) = review.storage_id {
|
||||||
let changed = transaction.execute(
|
let changed = transaction.execute(
|
||||||
"UPDATE reviews SET revision = ?1, summary = ?2, verdict = ?3 WHERE id = ?4 AND root = ?5 AND revision = ?6",
|
"UPDATE reviews SET revision = ?1, summary = ?2, verdict = ?3 WHERE id = ?4 AND root = ?5 AND revision = ?6 AND before_revision IS ?7 AND after_revision IS ?8",
|
||||||
params![revision, review.summary, verdict, id, root, review.storage_revision],
|
params![revision, review.summary, verdict, id, root, review.storage_revision, before, after],
|
||||||
).map_err(database_error)?;
|
).map_err(database_error)?;
|
||||||
if changed != 1 {
|
if changed != 1 {
|
||||||
return Err(
|
return Err(
|
||||||
"Review changed in another session; draft kept, reload before saving".into(),
|
"Review changed in another session or comparison scope changed; draft kept, reload before saving".into(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
id
|
id
|
||||||
} else {
|
} else {
|
||||||
transaction
|
transaction
|
||||||
.execute(
|
.execute(
|
||||||
"INSERT INTO reviews (root, revision, summary, verdict) VALUES (?1, ?2, ?3, ?4)",
|
"INSERT INTO reviews (root, revision, summary, verdict, before_revision, after_revision) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||||
params![root, revision, review.summary, verdict],
|
params![root, revision, review.summary, verdict, before, after],
|
||||||
)
|
)
|
||||||
.map_err(database_error)?;
|
.map_err(database_error)?;
|
||||||
transaction.last_insert_rowid()
|
transaction.last_insert_rowid()
|
||||||
@@ -888,9 +942,15 @@ fn stored_review(
|
|||||||
|row| row.get(0),
|
|row| row.get(0),
|
||||||
)
|
)
|
||||||
.map_err(database_error)?;
|
.map_err(database_error)?;
|
||||||
if stored_bytes < 0
|
let mut header_bytes = stored_root.len() + summary.len();
|
||||||
|| stored_bytes as u64 + stored_root.len() as u64 + summary.len() as u64 > LIMIT as u64
|
for index in [5, 6] {
|
||||||
{
|
if let rusqlite::types::ValueRef::Text(bytes) =
|
||||||
|
row.get_ref(index).map_err(database_error)?
|
||||||
|
{
|
||||||
|
header_bytes += bytes.len();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if stored_bytes < 0 || stored_bytes as u64 + header_bytes as u64 > LIMIT as u64 {
|
||||||
return Err("Review exceeds the 64 MiB limit".into());
|
return Err("Review exceeds the 64 MiB limit".into());
|
||||||
}
|
}
|
||||||
let mut review = Review {
|
let mut review = Review {
|
||||||
@@ -898,6 +958,14 @@ fn stored_review(
|
|||||||
..Review::default()
|
..Review::default()
|
||||||
};
|
};
|
||||||
review.summary = summary.to_owned();
|
review.summary = summary.to_owned();
|
||||||
|
review.comparison = match (
|
||||||
|
row.get::<_, Option<String>>(5).map_err(database_error)?,
|
||||||
|
row.get::<_, Option<String>>(6).map_err(database_error)?,
|
||||||
|
) {
|
||||||
|
(None, None) => None,
|
||||||
|
(Some(before), Some(after)) => Some([before, after]),
|
||||||
|
_ => return Err("Invalid stored review comparison endpoints".into()),
|
||||||
|
};
|
||||||
review.verdict = match row
|
review.verdict = match row
|
||||||
.get::<_, Option<String>>(4)
|
.get::<_, Option<String>>(4)
|
||||||
.map_err(database_error)?
|
.map_err(database_error)?
|
||||||
|
|||||||
+6
-1
@@ -109,6 +109,7 @@ fn envelope(review: &Review, all: bool, comments: bool) -> Value {
|
|||||||
"repository": review.root,
|
"repository": review.root,
|
||||||
"id": review.storage_id,
|
"id": review.storage_id,
|
||||||
"revision": review.storage_revision,
|
"revision": review.storage_revision,
|
||||||
|
"comparison": review.comparison,
|
||||||
"summary": review.summary,
|
"summary": review.summary,
|
||||||
"verdict": review.verdict,
|
"verdict": review.verdict,
|
||||||
"counts": { "total": review.comments.len(), "resolved": resolved, "unresolved": review.comments.len() - resolved },
|
"counts": { "total": review.comments.len(), "resolved": resolved, "unresolved": review.comments.len() - resolved },
|
||||||
@@ -166,7 +167,11 @@ pub fn run(request: &Request, config: Option<&Path>) -> Result<(), String> {
|
|||||||
vec![review]
|
vec![review]
|
||||||
} else {
|
} else {
|
||||||
match store {
|
match store {
|
||||||
Some(store) => store.list(&root)?,
|
Some(store) => store
|
||||||
|
.list(&root)?
|
||||||
|
.into_iter()
|
||||||
|
.filter(|review| !review.comments.is_empty())
|
||||||
|
.collect(),
|
||||||
None => Vec::new(),
|
None => Vec::new(),
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ pub struct Config {
|
|||||||
pub clipboard_mode: String,
|
pub clipboard_mode: String,
|
||||||
pub git_blame: bool,
|
pub git_blame: bool,
|
||||||
pub git_gutter: crate::gutter::GutterStyle,
|
pub git_gutter: crate::gutter::GutterStyle,
|
||||||
|
pub(crate) cursor: crate::cursor::CursorConfig,
|
||||||
pub top_bar: ChromeLine,
|
pub top_bar: ChromeLine,
|
||||||
pub bottom_bar: ChromeLine,
|
pub bottom_bar: ChromeLine,
|
||||||
pub popup: PopupConfig,
|
pub popup: PopupConfig,
|
||||||
@@ -101,6 +102,7 @@ impl Default for Config {
|
|||||||
clipboard_mode: "system".into(),
|
clipboard_mode: "system".into(),
|
||||||
git_blame: true,
|
git_blame: true,
|
||||||
git_gutter: crate::gutter::GutterStyle::default(),
|
git_gutter: crate::gutter::GutterStyle::default(),
|
||||||
|
cursor: crate::cursor::CursorConfig::default(),
|
||||||
top_bar: ChromeLine::top(),
|
top_bar: ChromeLine::top(),
|
||||||
bottom_bar: ChromeLine::bottom(),
|
bottom_bar: ChromeLine::bottom(),
|
||||||
popup: PopupConfig::default(),
|
popup: PopupConfig::default(),
|
||||||
@@ -146,6 +148,7 @@ pub fn load_config(path: &Path) -> Result<Config, String> {
|
|||||||
clipboard_mode,
|
clipboard_mode,
|
||||||
git_blame,
|
git_blame,
|
||||||
git_gutter,
|
git_gutter,
|
||||||
|
cursor,
|
||||||
top_bar,
|
top_bar,
|
||||||
bottom_bar,
|
bottom_bar,
|
||||||
popup,
|
popup,
|
||||||
@@ -192,6 +195,7 @@ pub fn load_config(path: &Path) -> Result<Config, String> {
|
|||||||
config.clipboard_mode = clipboard_mode;
|
config.clipboard_mode = clipboard_mode;
|
||||||
config.git_blame = git_blame;
|
config.git_blame = git_blame;
|
||||||
config.git_gutter = git_gutter;
|
config.git_gutter = git_gutter;
|
||||||
|
config.cursor = cursor;
|
||||||
config.top_bar = top_bar;
|
config.top_bar = top_bar;
|
||||||
config.bottom_bar = bottom_bar;
|
config.bottom_bar = bottom_bar;
|
||||||
config.popup = popup;
|
config.popup = popup;
|
||||||
@@ -558,6 +562,8 @@ struct ConfigFrame {
|
|||||||
clipboard_mode: String,
|
clipboard_mode: String,
|
||||||
git_blame: bool,
|
git_blame: bool,
|
||||||
git_gutter: crate::gutter::GutterStyle,
|
git_gutter: crate::gutter::GutterStyle,
|
||||||
|
#[serde(default)]
|
||||||
|
cursor: crate::cursor::CursorConfig,
|
||||||
#[serde(deserialize_with = "crate::chrome::top")]
|
#[serde(deserialize_with = "crate::chrome::top")]
|
||||||
top_bar: ChromeLine,
|
top_bar: ChromeLine,
|
||||||
#[serde(deserialize_with = "crate::chrome::bottom")]
|
#[serde(deserialize_with = "crate::chrome::bottom")]
|
||||||
|
|||||||
@@ -142,6 +142,7 @@ module Vamp
|
|||||||
@clipboard_mode = "system"
|
@clipboard_mode = "system"
|
||||||
@git_blame = true
|
@git_blame = true
|
||||||
@git_gutter = "bar"
|
@git_gutter = "bar"
|
||||||
|
@cursor = {}
|
||||||
@bar = BarConfig.new
|
@bar = BarConfig.new
|
||||||
@popup = PopupConfig.new
|
@popup = PopupConfig.new
|
||||||
@labels = {}
|
@labels = {}
|
||||||
@@ -231,6 +232,32 @@ module Vamp
|
|||||||
@git_gutter = style
|
@git_gutter = style
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# @param options [Hash{String, Symbol => Boolean, String, Symbol}] mode_colors defaults to true; mode shapes default to block
|
||||||
|
# @return [Hash{String => Boolean, String}]
|
||||||
|
def cursor=(options)
|
||||||
|
raise TypeError, "cursor options must be a Hash" unless options.is_a?(Hash)
|
||||||
|
|
||||||
|
@cursor = options.each_with_object({}) do |(option, value), result|
|
||||||
|
option = option.to_s if option.is_a?(Symbol)
|
||||||
|
option = Vamp.string!(option, "cursor option")
|
||||||
|
raise ArgumentError, "duplicate cursor option: #{option}" if result.key?(option)
|
||||||
|
|
||||||
|
case option
|
||||||
|
when "mode_colors"
|
||||||
|
raise TypeError, "cursor mode_colors must be true or false" unless [true, false].include?(value)
|
||||||
|
when "normal", "insert", "command", "search", "visual"
|
||||||
|
value = value.to_s if value.is_a?(Symbol)
|
||||||
|
value = Vamp.string!(value, "cursor shape")
|
||||||
|
unless %w[block bar underline].include?(value)
|
||||||
|
raise ArgumentError, "cursor shape must be block, bar, or underline"
|
||||||
|
end
|
||||||
|
else
|
||||||
|
raise ArgumentError, "unknown cursor option: #{option}"
|
||||||
|
end
|
||||||
|
result[option] = value
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# @param values [Hash{String, Symbol => String}]
|
# @param values [Hash{String, Symbol => String}]
|
||||||
# @return [Hash]
|
# @return [Hash]
|
||||||
def labels=(values)
|
def labels=(values)
|
||||||
@@ -441,6 +468,7 @@ module Vamp
|
|||||||
"file_explorer_view" => @file_explorer_view, "clipboard_mode" => @clipboard_mode,
|
"file_explorer_view" => @file_explorer_view, "clipboard_mode" => @clipboard_mode,
|
||||||
"git_blame" => @git_blame,
|
"git_blame" => @git_blame,
|
||||||
"git_gutter" => @git_gutter,
|
"git_gutter" => @git_gutter,
|
||||||
|
"cursor" => @cursor,
|
||||||
"scroll_distance" => @scroll_distance,
|
"scroll_distance" => @scroll_distance,
|
||||||
"scroll_step_lines" => @scroll_step_lines,
|
"scroll_step_lines" => @scroll_step_lines,
|
||||||
"highlight_trailing_whitespace" => @highlight_trailing_whitespace,
|
"highlight_trailing_whitespace" => @highlight_trailing_whitespace,
|
||||||
|
|||||||
@@ -516,6 +516,7 @@ impl EditorSession {
|
|||||||
}),
|
}),
|
||||||
None => Config::default(),
|
None => Config::default(),
|
||||||
};
|
};
|
||||||
|
editor.cursor_config = config.cursor;
|
||||||
let (pull_request_preferences_store, mut preferences, notice) = match preferences_path {
|
let (pull_request_preferences_store, mut preferences, notice) = match preferences_path {
|
||||||
Some(path) => crate::pull_request_preferences::Store::load(path),
|
Some(path) => crate::pull_request_preferences::Store::load(path),
|
||||||
None => (
|
None => (
|
||||||
@@ -579,6 +580,7 @@ impl EditorSession {
|
|||||||
editor.set_tab_spaces(config.tab_spaces, config.language_tab_spaces);
|
editor.set_tab_spaces(config.tab_spaces, config.language_tab_spaces);
|
||||||
editor.set_diff_context_options(config.diff_context_lines, config.diff_expand_lines);
|
editor.set_diff_context_options(config.diff_context_lines, config.diff_expand_lines);
|
||||||
editor.set_diff_display_options(config.diff_view_mode, config.diff_split_orientation);
|
editor.set_diff_display_options(config.diff_view_mode, config.diff_split_orientation);
|
||||||
|
editor.diff_follow_working_copy = config.diff_follow_working_copy;
|
||||||
editor.pull_request_review_requested_symbol = config.pull_request_review_requested_symbol;
|
editor.pull_request_review_requested_symbol = config.pull_request_review_requested_symbol;
|
||||||
editor.pull_request_approved_symbol = config.pull_request_approved_symbol;
|
editor.pull_request_approved_symbol = config.pull_request_approved_symbol;
|
||||||
editor.resolve_history_layout = config.resolve_history_layout;
|
editor.resolve_history_layout = config.resolve_history_layout;
|
||||||
|
|||||||
+319
-96
@@ -28,8 +28,9 @@ use crossterm::{
|
|||||||
};
|
};
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
Frame, Terminal,
|
Frame, Terminal,
|
||||||
backend::CrosstermBackend,
|
backend::{Backend, ClearType, CrosstermBackend, WindowSize},
|
||||||
layout::{Constraint, Layout, Position, Rect},
|
buffer::Cell,
|
||||||
|
layout::{Constraint, Layout, Position, Rect, Size},
|
||||||
style::{Color, Modifier, Style},
|
style::{Color, Modifier, Style},
|
||||||
text::{Line, Span},
|
text::{Line, Span},
|
||||||
widgets::{Block, Borders, Clear, Paragraph, Wrap},
|
widgets::{Block, Borders, Clear, Paragraph, Wrap},
|
||||||
@@ -392,14 +393,9 @@ impl HistoryDiff {
|
|||||||
for column in 0..3 {
|
for column in 0..3 {
|
||||||
if self.wrapping[column]
|
if self.wrapping[column]
|
||||||
&& self.widths[column] > 0
|
&& self.widths[column] > 0
|
||||||
&& let Some(source) = self.comparison.column(column).source_row(row)
|
&& let Some((key, text)) = self.comparison.column(column).view().text_row(row)
|
||||||
{
|
{
|
||||||
let lines = self.comparison.column(column).lines;
|
height = height.max(self.wrap[column].segments(key, text).len());
|
||||||
height = height.max(
|
|
||||||
self.wrap[column]
|
|
||||||
.segments(source, lines.get(source).map_or("", String::as_str))
|
|
||||||
.len(),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
height
|
height
|
||||||
@@ -432,10 +428,9 @@ impl HistoryDiff {
|
|||||||
let mut final_segments = [false; 2];
|
let mut final_segments = [false; 2];
|
||||||
let projection = self.comparison.column(column);
|
let projection = self.comparison.column(column);
|
||||||
let side = usize::from(projection.focus == Focus::After);
|
let side = usize::from(projection.focus == Focus::After);
|
||||||
if let Some(source) = projection.source_row(self.top.display) {
|
if let Some((key, text)) = projection.view().text_row(self.top.display) {
|
||||||
let text = projection.lines.get(source).map_or("", String::as_str);
|
|
||||||
if self.wrapping[column] {
|
if self.wrapping[column] {
|
||||||
let parts = self.wrap[column].segments(source, text);
|
let parts = self.wrap[column].segments(key, text);
|
||||||
segments[side] = parts.get(self.top.continuation).copied();
|
segments[side] = parts.get(self.top.continuation).copied();
|
||||||
final_segments[side] = self.top.continuation + 1 == parts.len();
|
final_segments[side] = self.top.continuation + 1 == parts.len();
|
||||||
} else if self.top.continuation == 0 {
|
} else if self.top.continuation == 0 {
|
||||||
@@ -843,6 +838,7 @@ impl BufferViewports {
|
|||||||
.filter_map(|(_, pane)| pane.panels.diff.as_ref()),
|
.filter_map(|(_, pane)| pane.panels.diff.as_ref()),
|
||||||
)
|
)
|
||||||
.any(|geometry| geometry.viewport.is_some());
|
.any(|geometry| geometry.viewport.is_some());
|
||||||
|
paint_cursor(frame, editor, self.panels.cursor);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_resolver_history(
|
fn draw_resolver_history(
|
||||||
@@ -1296,29 +1292,149 @@ fn wheel_kind(mouse: &MouseEvent) -> Option<MouseEventKind> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn mode_accent(mode: Mode) -> Color {
|
||||||
|
match mode {
|
||||||
|
Mode::Normal => Color::Cyan,
|
||||||
|
Mode::Insert => Color::Green,
|
||||||
|
Mode::Visual => Color::Magenta,
|
||||||
|
Mode::Command => Color::Yellow,
|
||||||
|
Mode::Search => Color::Blue,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mode_style(mode: Mode) -> Style {
|
||||||
|
Style::default()
|
||||||
|
.fg(if mode == Mode::Search {
|
||||||
|
Color::White
|
||||||
|
} else {
|
||||||
|
Color::Black
|
||||||
|
})
|
||||||
|
.bg(mode_accent(mode))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn paint_cursor(frame: &mut Frame, editor: &Editor, position: Option<Position>) {
|
||||||
|
use crate::cursor::CursorShape;
|
||||||
|
let Some(position) = position.filter(|&position| frame.area().contains(position)) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let shape = editor.cursor_config.shape(editor.mode());
|
||||||
|
let colored = editor.cursor_config.mode_colors;
|
||||||
|
let color = if colored {
|
||||||
|
mode_accent(editor.mode())
|
||||||
|
} else {
|
||||||
|
Color::Reset
|
||||||
|
};
|
||||||
|
let visible = Modifier::HIDDEN | Modifier::DIM | Modifier::REVERSED;
|
||||||
|
let style = match shape {
|
||||||
|
CursorShape::Block if colored => mode_style(editor.mode()).remove_modifier(visible),
|
||||||
|
CursorShape::Block => Style::default()
|
||||||
|
.remove_modifier(Modifier::HIDDEN | Modifier::DIM)
|
||||||
|
.add_modifier(Modifier::REVERSED),
|
||||||
|
CursorShape::Underline => Style::default()
|
||||||
|
.fg(color)
|
||||||
|
.underline_color(color)
|
||||||
|
.remove_modifier(visible)
|
||||||
|
.add_modifier(Modifier::UNDERLINED),
|
||||||
|
CursorShape::Bar => Style::default().fg(color).remove_modifier(visible),
|
||||||
|
};
|
||||||
|
let buffer = frame.buffer_mut();
|
||||||
|
let width = buffer[position]
|
||||||
|
.symbol()
|
||||||
|
.width()
|
||||||
|
.max(1)
|
||||||
|
.min(usize::from(buffer.area.right() - position.x));
|
||||||
|
if shape == CursorShape::Bar {
|
||||||
|
// A cell-rendered bar overlays the hovered glyph; source text stays intact.
|
||||||
|
buffer[position].set_symbol("▏");
|
||||||
|
}
|
||||||
|
for offset in 0..width {
|
||||||
|
buffer[(position.x + offset as u16, position.y)].set_style(style);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep Ratatui's logical cursor position (including IME placement), but never
|
||||||
|
// show a hardware cursor on top of the cell-rendered caret.
|
||||||
|
struct SoftwareCursorBackend<B>(B);
|
||||||
|
|
||||||
|
impl<B: Backend> Backend for SoftwareCursorBackend<B> {
|
||||||
|
type Error = B::Error;
|
||||||
|
|
||||||
|
fn draw<'a, I>(&mut self, content: I) -> Result<(), Self::Error>
|
||||||
|
where
|
||||||
|
I: Iterator<Item = (u16, u16, &'a Cell)>,
|
||||||
|
{
|
||||||
|
self.0.draw(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn append_lines(&mut self, count: u16) -> Result<(), Self::Error> {
|
||||||
|
self.0.append_lines(count)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hide_cursor(&mut self) -> Result<(), Self::Error> {
|
||||||
|
self.0.hide_cursor()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn show_cursor(&mut self) -> Result<(), Self::Error> {
|
||||||
|
self.0.hide_cursor()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_cursor_position(&mut self) -> Result<Position, Self::Error> {
|
||||||
|
self.0.get_cursor_position()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_cursor_position<P: Into<Position>>(&mut self, position: P) -> Result<(), Self::Error> {
|
||||||
|
self.0.set_cursor_position(position)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clear(&mut self) -> Result<(), Self::Error> {
|
||||||
|
self.0.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clear_region(&mut self, clear_type: ClearType) -> Result<(), Self::Error> {
|
||||||
|
self.0.clear_region(clear_type)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn size(&self) -> Result<Size, Self::Error> {
|
||||||
|
self.0.size()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn window_size(&mut self) -> Result<WindowSize, Self::Error> {
|
||||||
|
self.0.window_size()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flush(&mut self) -> Result<(), Self::Error> {
|
||||||
|
self.0.flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_cursor(frame: &mut Frame, panels: &mut WheelPanels, position: Position) {
|
||||||
|
panels.cursor = Some(position);
|
||||||
|
frame.set_cursor_position(position);
|
||||||
|
}
|
||||||
|
|
||||||
fn draw_frame<W: io::Write>(
|
fn draw_frame<W: io::Write>(
|
||||||
terminal: &mut Terminal<CrosstermBackend<Vec<u8>>>,
|
terminal: &mut Terminal<SoftwareCursorBackend<CrosstermBackend<Vec<u8>>>>,
|
||||||
output: &mut W,
|
output: &mut W,
|
||||||
clear_screen: bool,
|
clear_screen: bool,
|
||||||
render: impl FnOnce(&mut Frame),
|
render: impl FnOnce(&mut Frame),
|
||||||
) -> io::Result<()> {
|
) -> io::Result<()> {
|
||||||
// Do not suspend the terminal's presentation while computing the next frame.
|
// Do not suspend the terminal's presentation while computing the next frame.
|
||||||
terminal.backend_mut().writer_mut().clear();
|
terminal.backend_mut().0.writer_mut().clear();
|
||||||
crossterm::queue!(terminal.backend_mut(), BeginSynchronizedUpdate)?;
|
crossterm::queue!(terminal.backend_mut().0, BeginSynchronizedUpdate)?;
|
||||||
// Terminals without synchronized updates still hide the painting cursor.
|
// Terminals without synchronized updates still hide the painting cursor.
|
||||||
let result = terminal.hide_cursor().and_then(|()| {
|
let result = terminal.hide_cursor().and_then(|()| {
|
||||||
if clear_screen {
|
if clear_screen {
|
||||||
// Fresh Ratatui buffers are already empty; avoid its blocking cursor query.
|
// Fresh Ratatui buffers are already empty; avoid its blocking cursor query.
|
||||||
crossterm::queue!(
|
crossterm::queue!(
|
||||||
terminal.backend_mut(),
|
terminal.backend_mut().0,
|
||||||
crossterm::terminal::Clear(crossterm::terminal::ClearType::All)
|
crossterm::terminal::Clear(crossterm::terminal::ClearType::All)
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
terminal.draw(render).map(|_| ())
|
terminal.draw(render).map(|_| ())
|
||||||
});
|
});
|
||||||
let finish = execute!(terminal.backend_mut(), EndSynchronizedUpdate);
|
let finish = execute!(terminal.backend_mut().0, EndSynchronizedUpdate);
|
||||||
result.and(finish)?;
|
result.and(finish)?;
|
||||||
output.write_all(terminal.backend().writer())?;
|
output.write_all(terminal.backend().0.writer())?;
|
||||||
output.flush()
|
output.flush()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1326,7 +1442,7 @@ fn draw_frame<W: io::Write>(
|
|||||||
/// inside the first synchronized frame rather than exposing a blank screen.
|
/// inside the first synchronized frame rather than exposing a blank screen.
|
||||||
pub fn run(session: &mut EditorSession, mut clear_screen: bool) -> io::Result<()> {
|
pub fn run(session: &mut EditorSession, mut clear_screen: bool) -> io::Result<()> {
|
||||||
let mut output: Stdout = io::stdout();
|
let mut output: Stdout = io::stdout();
|
||||||
let mut terminal = Terminal::new(CrosstermBackend::new(Vec::new()))?;
|
let mut terminal = Terminal::new(SoftwareCursorBackend(CrosstermBackend::new(Vec::new())))?;
|
||||||
let mut viewports = BufferViewports::default();
|
let mut viewports = BufferViewports::default();
|
||||||
let mut colors = TerminalColors::default();
|
let mut colors = TerminalColors::default();
|
||||||
let mut query_theme = true;
|
let mut query_theme = true;
|
||||||
@@ -1588,6 +1704,7 @@ struct ChromeItem<'a> {
|
|||||||
config: &'a SegmentConfig,
|
config: &'a SegmentConfig,
|
||||||
tab: Option<usize>,
|
tab: Option<usize>,
|
||||||
accent: bool,
|
accent: bool,
|
||||||
|
mode_style: Style,
|
||||||
bold: bool,
|
bold: bool,
|
||||||
first: bool,
|
first: bool,
|
||||||
mandatory: bool,
|
mandatory: bool,
|
||||||
@@ -1712,6 +1829,7 @@ fn chrome_items<'a>(
|
|||||||
config,
|
config,
|
||||||
tab: Some(index),
|
tab: Some(index),
|
||||||
accent: active,
|
accent: active,
|
||||||
|
mode_style: mode_style(editor.mode()),
|
||||||
bold: active,
|
bold: active,
|
||||||
first: index == 0,
|
first: index == 0,
|
||||||
mandatory: active,
|
mandatory: active,
|
||||||
@@ -1837,6 +1955,7 @@ fn chrome_items<'a>(
|
|||||||
text,
|
text,
|
||||||
config,
|
config,
|
||||||
tab: None,
|
tab: None,
|
||||||
|
mode_style: mode_style(editor.mode()),
|
||||||
accent: !matches!(
|
accent: !matches!(
|
||||||
name.as_str(),
|
name.as_str(),
|
||||||
"path"
|
"path"
|
||||||
@@ -1955,7 +2074,7 @@ fn draw_chrome(frame: &mut Frame, mut groups: [Vec<ChromeItem<'_>>; 3], area: Re
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
let available = width - x;
|
let available = width - x;
|
||||||
let highlight = Style::default().fg(Color::Black).bg(Color::Cyan);
|
let highlight = item.mode_style;
|
||||||
let highlight = if item.bold {
|
let highlight = if item.bold {
|
||||||
highlight.add_modifier(Modifier::BOLD)
|
highlight.add_modifier(Modifier::BOLD)
|
||||||
} else {
|
} else {
|
||||||
@@ -1979,7 +2098,7 @@ fn draw_chrome(frame: &mut Frame, mut groups: [Vec<ChromeItem<'_>>; 3], area: Re
|
|||||||
let leading_margin = &PADDING[..item.config.margin[0]];
|
let leading_margin = &PADDING[..item.config.margin[0]];
|
||||||
let trailing_margin = &PADDING[..item.config.margin[1]];
|
let trailing_margin = &PADDING[..item.config.margin[1]];
|
||||||
let edges = item.tab.is_none() || item.accent;
|
let edges = item.tab.is_none() || item.accent;
|
||||||
let edge = Style::default().fg(Color::Cyan);
|
let edge = Style::default().fg(item.mode_style.bg.unwrap_or(Color::Reset));
|
||||||
Line::from(vec![
|
Line::from(vec![
|
||||||
Span::styled(leading_margin, Style::reset()),
|
Span::styled(leading_margin, Style::reset()),
|
||||||
Span::styled(if edges { item.leading_edge() } else { "" }, edge),
|
Span::styled(if edges { item.leading_edge() } else { "" }, edge),
|
||||||
@@ -2793,7 +2912,7 @@ fn draw_document(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_floating_input(frame: &mut Frame, editor: &Editor, area: Rect) -> Option<Rect> {
|
fn draw_floating_input(frame: &mut Frame, editor: &Editor, area: Rect) -> Option<(Rect, Position)> {
|
||||||
if area.width == 0 || area.height == 0 {
|
if area.width == 0 || area.height == 0 {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -2854,7 +2973,10 @@ fn draw_floating_input(frame: &mut Frame, editor: &Editor, area: Rect) -> Option
|
|||||||
content,
|
content,
|
||||||
);
|
);
|
||||||
frame.set_cursor_position((content.x + (column - offset) as u16, content.y));
|
frame.set_cursor_position((content.x + (column - offset) as u16, content.y));
|
||||||
Some(panel)
|
Some((
|
||||||
|
panel,
|
||||||
|
Position::new(content.x + (column - offset) as u16, content.y),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_notification(frame: &mut Frame, editor: &Editor, area: Rect) -> Option<Rect> {
|
fn draw_notification(frame: &mut Frame, editor: &Editor, area: Rect) -> Option<Rect> {
|
||||||
@@ -2938,7 +3060,10 @@ fn draw_overlays(
|
|||||||
.is_some_and(|picker| picker.presentation == FilePresentation::Floating);
|
.is_some_and(|picker| picker.presentation == FilePresentation::Floating);
|
||||||
if !covered {
|
if !covered {
|
||||||
if matches!(editor.mode(), Mode::Command | Mode::Search) {
|
if matches!(editor.mode(), Mode::Command | Mode::Search) {
|
||||||
panels.input = draw_floating_input(frame, editor, area);
|
if let Some((panel, cursor)) = draw_floating_input(frame, editor, area) {
|
||||||
|
panels.input = Some(panel);
|
||||||
|
panels.cursor = Some(cursor);
|
||||||
|
}
|
||||||
} else if editor.diff().is_none_or(|diff| diff.phase == Phase::View)
|
} else if editor.diff().is_none_or(|diff| diff.phase == Phase::View)
|
||||||
&& !editor.file_picker().is_some_and(|picker| picker.focused)
|
&& !editor.file_picker().is_some_and(|picker| picker.focused)
|
||||||
{
|
{
|
||||||
@@ -2953,7 +3078,7 @@ fn draw_overlays(
|
|||||||
.notification
|
.notification
|
||||||
.is_some_and(|panel| panel.contains(position))
|
.is_some_and(|panel| panel.contains(position))
|
||||||
{
|
{
|
||||||
frame.set_cursor_position(position);
|
set_cursor(frame, panels, position);
|
||||||
}
|
}
|
||||||
if let Some(completion) = editor.completion()
|
if let Some(completion) = editor.completion()
|
||||||
&& let Some((_, caret)) = caret
|
&& let Some((_, caret)) = caret
|
||||||
@@ -2992,10 +3117,11 @@ fn draw_overlays(
|
|||||||
draw_hover(frame, hover, area, panels);
|
draw_hover(frame, hover, area, panels);
|
||||||
}
|
}
|
||||||
if let Some(diagnostic) = editor.startup_diagnostic() {
|
if let Some(diagnostic) = editor.startup_diagnostic() {
|
||||||
draw_startup_diagnostic(frame, diagnostic, area);
|
panels.cursor = draw_diagnostic_panel(frame, diagnostic, area, "Configuration error", None);
|
||||||
}
|
}
|
||||||
if let Some(output) = editor.command_output() {
|
if let Some(output) = editor.command_output() {
|
||||||
draw_diagnostic_panel(frame, &output.content, area, &output.title, Some(output));
|
panels.cursor =
|
||||||
|
draw_diagnostic_panel(frame, &output.content, area, &output.title, Some(output));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3318,7 +3444,11 @@ fn paint_editor_line(
|
|||||||
left,
|
left,
|
||||||
column,
|
column,
|
||||||
column.saturating_add(1),
|
column.saturating_add(1),
|
||||||
Style::default().fg(Color::Black).bg(Color::Magenta),
|
if editor.cursor_config.mode_colors {
|
||||||
|
mode_style(editor.mode())
|
||||||
|
} else {
|
||||||
|
Style::default().fg(Color::Black).bg(Color::Magenta)
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3458,6 +3588,8 @@ fn draw_diff(
|
|||||||
}
|
}
|
||||||
} else if pull_requests {
|
} else if pull_requests {
|
||||||
" Pull requests ".into()
|
" Pull requests ".into()
|
||||||
|
} else if panel.width < 50 {
|
||||||
|
format!(" Choose {stage} ")
|
||||||
} else {
|
} else {
|
||||||
format!(" Diff · Choose {stage} · {} ", diff.comparison_mode.label())
|
format!(" Diff · Choose {stage} · {} ", diff.comparison_mode.label())
|
||||||
};
|
};
|
||||||
@@ -3570,10 +3702,6 @@ fn draw_diff(
|
|||||||
Constraint::Length(u16::from(content.height >= 7)),
|
Constraint::Length(u16::from(content.height >= 7)),
|
||||||
])
|
])
|
||||||
.areas(content);
|
.areas(content);
|
||||||
let before = diff
|
|
||||||
.before
|
|
||||||
.as_ref()
|
|
||||||
.map_or("not selected", |revision| revision.label.as_str());
|
|
||||||
let stage_text = if pull_requests {
|
let stage_text = if pull_requests {
|
||||||
let filters = &diff.pull_request_preferences.filters;
|
let filters = &diff.pull_request_preferences.filters;
|
||||||
let review = if filters.len() == 3 {
|
let review = if filters.len() == 3 {
|
||||||
@@ -3605,11 +3733,7 @@ fn draw_diff(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
format!(
|
format!("{} · Ctrl+b toggle", diff.comparison_mode.label())
|
||||||
"{} · Ctrl+b toggle · Before: {}",
|
|
||||||
diff.comparison_mode.label(),
|
|
||||||
display_text(before)
|
|
||||||
)
|
|
||||||
};
|
};
|
||||||
frame.render_widget(Paragraph::new(stage_text), stage_area);
|
frame.render_widget(Paragraph::new(stage_text), stage_area);
|
||||||
if pull_requests || diff.catalog.is_some() {
|
if pull_requests || diff.catalog.is_some() {
|
||||||
@@ -3654,7 +3778,11 @@ fn draw_diff(
|
|||||||
&& query_area.height > 0
|
&& query_area.height > 0
|
||||||
&& editor.is_none_or(|editor| editor.render_pane_focused())
|
&& editor.is_none_or(|editor| editor.render_pane_focused())
|
||||||
{
|
{
|
||||||
frame.set_cursor_position((query_area.x + (query_width - offset) as u16, query_area.y));
|
set_cursor(
|
||||||
|
frame,
|
||||||
|
panels,
|
||||||
|
Position::new(query_area.x + (query_width - offset) as u16, query_area.y),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if let Some(notice) = notice {
|
if let Some(notice) = notice {
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
@@ -3669,11 +3797,34 @@ fn draw_diff(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
let mut entries = diff.entries();
|
let mut entries = diff.entries();
|
||||||
|
let before_matches = |revision: &crate::diff::Revision| {
|
||||||
|
diff.before.as_ref().is_some_and(|before| {
|
||||||
|
match (diff.graph_revision(before), diff.graph_revision(revision)) {
|
||||||
|
(Some((before, _)), Some((candidate, _))) => before == candidate,
|
||||||
|
_ => before.target == revision.target,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
if !pull_requests && let Some(working) = entries.next() {
|
if !pull_requests && let Some(working) = entries.next() {
|
||||||
|
let follow = diff.phase == Phase::After && diff.follow_working_copy;
|
||||||
let label = format!(
|
let label = format!(
|
||||||
"{} {} · pinned",
|
"{} {}{} · pinned · {}",
|
||||||
if diff.selected == 0 { ">" } else { " " },
|
if diff.selected == 0 { ">" } else { " " },
|
||||||
display_text(&working.label)
|
if follow {
|
||||||
|
"Follow checkout"
|
||||||
|
} else {
|
||||||
|
"Checkout snapshot"
|
||||||
|
},
|
||||||
|
if before_matches(working) {
|
||||||
|
" [Before]"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
},
|
||||||
|
if follow {
|
||||||
|
"Live files; follows descendant checkouts"
|
||||||
|
} else {
|
||||||
|
"Live files at this checkout"
|
||||||
|
},
|
||||||
);
|
);
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
Paragraph::new(visible_line(&label, 0, usize::from(pinned_area.width))).style(
|
Paragraph::new(visible_line(&label, 0, usize::from(pinned_area.width))).style(
|
||||||
@@ -3742,6 +3893,49 @@ fn draw_diff(
|
|||||||
} else {
|
} else {
|
||||||
Style::default()
|
Style::default()
|
||||||
};
|
};
|
||||||
|
let before_badge = if before_matches(revision) {
|
||||||
|
" [Before]"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
if revision.category == crate::diff::Category::Revisions
|
||||||
|
&& let Some((id, metadata)) = diff.graph_revision(revision)
|
||||||
|
{
|
||||||
|
let width = usize::from(results.width);
|
||||||
|
let change = metadata.change_id.as_str();
|
||||||
|
let change = change.get(..12).unwrap_or(change);
|
||||||
|
let label = format!(
|
||||||
|
"{}{before_badge} {} · {}",
|
||||||
|
if index == diff.selected { ">" } else { " " },
|
||||||
|
display_text(change),
|
||||||
|
display_text(&metadata.description),
|
||||||
|
);
|
||||||
|
lines.push(Line::styled(visible_line(&label, 0, width), style));
|
||||||
|
if row_height == 2 {
|
||||||
|
let details = format!(
|
||||||
|
" {}{}{}{} · {} · {}",
|
||||||
|
if metadata.current { "[checkout] " } else { "" },
|
||||||
|
if metadata.immutable {
|
||||||
|
"[immutable] "
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
},
|
||||||
|
if metadata.bookmarks.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!("[{}] ", display_text(&metadata.bookmarks))
|
||||||
|
},
|
||||||
|
display_text(&metadata.author),
|
||||||
|
display_text(&metadata.timestamp),
|
||||||
|
&id[..id.len().min(12)],
|
||||||
|
);
|
||||||
|
lines.push(Line::styled(
|
||||||
|
visible_line(&details, 0, width),
|
||||||
|
style.fg(Color::DarkGray),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let symbol = if pull_requests {
|
let symbol = if pull_requests {
|
||||||
match diff.pull_request_review_state(revision) {
|
match diff.pull_request_review_state(revision) {
|
||||||
crate::diff::PullRequestReviewState::Requested => requested_symbol,
|
crate::diff::PullRequestReviewState::Requested => requested_symbol,
|
||||||
@@ -3752,12 +3946,17 @@ fn draw_diff(
|
|||||||
""
|
""
|
||||||
};
|
};
|
||||||
let label = format!(
|
let label = format!(
|
||||||
"{} {symbol}{:padding$}{}",
|
"{} {symbol}{:padding$}{}{before_badge}",
|
||||||
if index == diff.selected { ">" } else { " " },
|
if index == diff.selected { ">" } else { " " },
|
||||||
"",
|
"",
|
||||||
display_text(&revision.label),
|
display_text(&revision.label),
|
||||||
padding = decoration_width.saturating_sub(symbol.width()),
|
padding = decoration_width.saturating_sub(symbol.width()),
|
||||||
);
|
);
|
||||||
|
let description = diff
|
||||||
|
.graph_revision(revision)
|
||||||
|
.map_or(revision.description.as_str(), |(_, metadata)| {
|
||||||
|
metadata.description.as_str()
|
||||||
|
});
|
||||||
let width = usize::from(results.width);
|
let width = usize::from(results.width);
|
||||||
if row_height == 2 {
|
if row_height == 2 {
|
||||||
lines.push(Line::styled(visible_line(&label, 0, width), style));
|
lines.push(Line::styled(visible_line(&label, 0, width), style));
|
||||||
@@ -3766,7 +3965,7 @@ fn draw_diff(
|
|||||||
&format!(
|
&format!(
|
||||||
"{:indent$}{}",
|
"{:indent$}{}",
|
||||||
"",
|
"",
|
||||||
display_text(&revision.description),
|
display_text(description),
|
||||||
indent = 2 + decoration_width,
|
indent = 2 + decoration_width,
|
||||||
),
|
),
|
||||||
0,
|
0,
|
||||||
@@ -3777,7 +3976,7 @@ fn draw_diff(
|
|||||||
} else {
|
} else {
|
||||||
lines.push(Line::styled(
|
lines.push(Line::styled(
|
||||||
visible_line(
|
visible_line(
|
||||||
&format!("{label} · {}", display_text(&revision.description)),
|
&format!("{label} · {}", display_text(description)),
|
||||||
0,
|
0,
|
||||||
width,
|
width,
|
||||||
),
|
),
|
||||||
@@ -4682,6 +4881,7 @@ fn paint_diff_content<'a>(
|
|||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let before = line.side == Focus::Before;
|
let before = line.side == Focus::Before;
|
||||||
|
let removed = line.polarity == Focus::Before;
|
||||||
let index = line.source_row;
|
let index = line.source_row;
|
||||||
let segment = segments.and_then(|segments| segments[usize::from(!before)]);
|
let segment = segments.and_then(|segments| segments[usize::from(!before)]);
|
||||||
let left = segment.map_or(left, |segment| segment.start);
|
let left = segment.map_or(left, |segment| segment.start);
|
||||||
@@ -4694,7 +4894,7 @@ fn paint_diff_content<'a>(
|
|||||||
let text_area = Rect::new(content.x + gutter as u16, row_area.y, paint_width as u16, 1);
|
let text_area = Rect::new(content.x + gutter as u16, row_area.y, paint_width as u16, 1);
|
||||||
let changed = line.changed;
|
let changed = line.changed;
|
||||||
let background = if changed && line.emphasize {
|
let background = if changed && line.emphasize {
|
||||||
colors.row[usize::from(!before)]
|
colors.row[usize::from(!removed)]
|
||||||
} else {
|
} else {
|
||||||
Color::Reset
|
Color::Reset
|
||||||
};
|
};
|
||||||
@@ -4711,12 +4911,12 @@ fn paint_diff_content<'a>(
|
|||||||
);
|
);
|
||||||
if gutter > 0 && wrapped.is_none_or(|rows| rows[screen].position.continuation == 0) {
|
if gutter > 0 && wrapped.is_none_or(|rows| rows[screen].position.continuation == 0) {
|
||||||
let marker = if changed {
|
let marker = if changed {
|
||||||
if before { '-' } else { '+' }
|
if removed { '-' } else { '+' }
|
||||||
} else {
|
} else {
|
||||||
' '
|
' '
|
||||||
};
|
};
|
||||||
let color = if changed {
|
let color = if changed {
|
||||||
if before { Color::Red } else { Color::Green }
|
if removed { Color::Red } else { Color::Green }
|
||||||
} else {
|
} else {
|
||||||
Color::DarkGray
|
Color::DarkGray
|
||||||
};
|
};
|
||||||
@@ -4732,35 +4932,41 @@ fn paint_diff_content<'a>(
|
|||||||
}
|
}
|
||||||
(Some(old), None) => format!("{old:>old_digits$} {:>new_digits$}{marker} ", ""),
|
(Some(old), None) => format!("{old:>old_digits$} {:>new_digits$}{marker} ", ""),
|
||||||
(None, Some(new)) => format!("{:>old_digits$} {new:>new_digits$}{marker} ", ""),
|
(None, Some(new)) => format!("{:>old_digits$} {new:>new_digits$}{marker} ", ""),
|
||||||
(None, None) => unreachable!("displayed diff line has a source"),
|
(None, None) => format!("{:>old_digits$} {:>new_digits$}{marker} ", "", ""),
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let digits = view.info.source_extents[usize::from(!before)]
|
let digits = view.info.source_extents[usize::from(!before)]
|
||||||
.max(1)
|
.max(1)
|
||||||
.ilog10() as usize
|
.ilog10() as usize
|
||||||
+ 1;
|
+ 1;
|
||||||
format!("{:>digits$}{marker} ", index + 1)
|
if line.native {
|
||||||
|
format!("{:>digits$}{marker} ", index + 1)
|
||||||
|
} else {
|
||||||
|
format!("{:>digits$}{marker} ", "")
|
||||||
|
}
|
||||||
};
|
};
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
Paragraph::new(label).style(Style::default().fg(color).bg(background)),
|
Paragraph::new(label).style(Style::default().fg(color).bg(background)),
|
||||||
Rect::new(content.x, row_area.y, gutter as u16, 1),
|
Rect::new(content.x, row_area.y, gutter as u16, 1),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
paint_syntax_line(
|
if line.native {
|
||||||
frame,
|
paint_syntax_line(
|
||||||
source_text,
|
frame,
|
||||||
syntax[usize::from(!before)].line(index),
|
source_text,
|
||||||
text_area,
|
syntax[usize::from(!before)].line(index),
|
||||||
left,
|
text_area,
|
||||||
);
|
left,
|
||||||
paint_semantic_line(
|
);
|
||||||
frame,
|
paint_semantic_line(
|
||||||
source_text,
|
frame,
|
||||||
semantic[usize::from(!before)],
|
source_text,
|
||||||
index,
|
semantic[usize::from(!before)],
|
||||||
text_area,
|
index,
|
||||||
left,
|
text_area,
|
||||||
);
|
left,
|
||||||
|
);
|
||||||
|
}
|
||||||
if let Some(characters) = whitespace_characters {
|
if let Some(characters) = whitespace_characters {
|
||||||
paint_whitespace(
|
paint_whitespace(
|
||||||
frame,
|
frame,
|
||||||
@@ -4769,13 +4975,17 @@ fn paint_diff_content<'a>(
|
|||||||
left,
|
left,
|
||||||
characters,
|
characters,
|
||||||
whitespace_scope,
|
whitespace_scope,
|
||||||
syntax[usize::from(!before)].string_spans(index),
|
if line.native {
|
||||||
|
syntax[usize::from(!before)].string_spans(index)
|
||||||
|
} else {
|
||||||
|
&[]
|
||||||
|
},
|
||||||
line.newline,
|
line.newline,
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if line.emphasize {
|
if line.emphasize {
|
||||||
let style = Style::default().bg(colors.character[usize::from(!before)]);
|
let style = Style::default().bg(colors.character[usize::from(!removed)]);
|
||||||
for range in line.changes() {
|
for range in line.changes() {
|
||||||
if !range.is_empty() {
|
if !range.is_empty() {
|
||||||
paint_grapheme_range(frame, text_area, left, source_text, range.clone(), style);
|
paint_grapheme_range(frame, text_area, left, source_text, range.clone(), style);
|
||||||
@@ -4892,17 +5102,13 @@ fn floating_panel(area: Rect) -> Rect {
|
|||||||
panel
|
panel
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_startup_diagnostic(frame: &mut Frame, diagnostic: &StartupDiagnostic, area: Rect) {
|
|
||||||
draw_diagnostic_panel(frame, diagnostic, area, "Configuration error", None);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn draw_diagnostic_panel(
|
fn draw_diagnostic_panel(
|
||||||
frame: &mut Frame,
|
frame: &mut Frame,
|
||||||
diagnostic: &StartupDiagnostic,
|
diagnostic: &StartupDiagnostic,
|
||||||
area: Rect,
|
area: Rect,
|
||||||
title: &str,
|
title: &str,
|
||||||
output: Option<&crate::editor::ShellOutput>,
|
output: Option<&crate::editor::ShellOutput>,
|
||||||
) {
|
) -> Option<Position> {
|
||||||
let startup = output.is_none();
|
let startup = output.is_none();
|
||||||
let panel = floating_panel(area);
|
let panel = floating_panel(area);
|
||||||
frame.render_widget(Clear, panel);
|
frame.render_widget(Clear, panel);
|
||||||
@@ -4983,7 +5189,11 @@ fn draw_diagnostic_panel(
|
|||||||
help_area,
|
help_area,
|
||||||
);
|
);
|
||||||
if startup && text_area.width > 0 && text_area.height > 0 {
|
if startup && text_area.width > 0 && text_area.height > 0 {
|
||||||
frame.set_cursor_position((text_area.x, text_area.y));
|
let position = Position::new(text_area.x, text_area.y);
|
||||||
|
frame.set_cursor_position(position);
|
||||||
|
Some(position)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5080,7 +5290,7 @@ impl DiffWrapViewport {
|
|||||||
fn row(
|
fn row(
|
||||||
&mut self,
|
&mut self,
|
||||||
diff: &DiffState,
|
diff: &DiffState,
|
||||||
file: &ChangedFile,
|
_file: &ChangedFile,
|
||||||
position: DiffWrapPosition,
|
position: DiffWrapPosition,
|
||||||
) -> WrappedDiffRow {
|
) -> WrappedDiffRow {
|
||||||
let mut segments = [None; 2];
|
let mut segments = [None; 2];
|
||||||
@@ -5099,15 +5309,16 @@ impl DiffWrapViewport {
|
|||||||
Some(DisplayRow::Line(_))
|
Some(DisplayRow::Line(_))
|
||||||
) {
|
) {
|
||||||
for (side, focus) in [Focus::Before, Focus::After].into_iter().enumerate() {
|
for (side, focus) in [Focus::Before, Focus::After].into_iter().enumerate() {
|
||||||
let source = diff.display_source_row(focus, position.display);
|
let view = diff.column_view(focus).unwrap();
|
||||||
if let Some(source) = source.filter(|_| self.widths[side] > 0) {
|
if let Some((key, text)) = view.text_row(position.display)
|
||||||
let (cache, lines) = if side == 0 {
|
&& self.widths[side] > 0
|
||||||
(&mut self.before, &file.before)
|
{
|
||||||
|
let cache = if side == 0 {
|
||||||
|
&mut self.before
|
||||||
} else {
|
} else {
|
||||||
(&mut self.after, &file.after)
|
&mut self.after
|
||||||
};
|
};
|
||||||
let text = lines.get(source).map_or("", String::as_str);
|
let parts = cache.segments(key, text);
|
||||||
let parts = cache.segments(source, text);
|
|
||||||
segments[side] = parts.get(position.continuation).copied();
|
segments[side] = parts.get(position.continuation).copied();
|
||||||
final_segments[side] = position.continuation + 1 == parts.len();
|
final_segments[side] = position.continuation + 1 == parts.len();
|
||||||
}
|
}
|
||||||
@@ -5122,7 +5333,7 @@ impl DiffWrapViewport {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn height(&mut self, diff: &DiffState, file: &ChangedFile, display: usize) -> usize {
|
fn height(&mut self, diff: &DiffState, _file: &ChangedFile, display: usize) -> usize {
|
||||||
if !diff.word_wrap {
|
if !diff.word_wrap {
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
@@ -5131,18 +5342,16 @@ impl DiffWrapViewport {
|
|||||||
}
|
}
|
||||||
let mut height = 1;
|
let mut height = 1;
|
||||||
for (side, focus) in [Focus::Before, Focus::After].into_iter().enumerate() {
|
for (side, focus) in [Focus::Before, Focus::After].into_iter().enumerate() {
|
||||||
let source = diff.display_source_row(focus, display);
|
let view = diff.column_view(focus).unwrap();
|
||||||
if let Some(source) = source.filter(|_| self.widths[side] > 0) {
|
if let Some((key, text)) = view.text_row(display)
|
||||||
let (cache, lines) = if side == 0 {
|
&& self.widths[side] > 0
|
||||||
(&mut self.before, &file.before)
|
{
|
||||||
|
let cache = if side == 0 {
|
||||||
|
&mut self.before
|
||||||
} else {
|
} else {
|
||||||
(&mut self.after, &file.after)
|
&mut self.after
|
||||||
};
|
};
|
||||||
height = height.max(
|
height = height.max(cache.segments(key, text).len());
|
||||||
cache
|
|
||||||
.segments(source, lines.get(source).map_or("", String::as_str))
|
|
||||||
.len(),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
height
|
height
|
||||||
@@ -5703,6 +5912,7 @@ impl Default for DiffColors {
|
|||||||
struct WheelPanels {
|
struct WheelPanels {
|
||||||
workspace_managed: bool,
|
workspace_managed: bool,
|
||||||
caret: Option<(Rect, Position)>,
|
caret: Option<(Rect, Position)>,
|
||||||
|
cursor: Option<Position>,
|
||||||
input: Option<Rect>,
|
input: Option<Rect>,
|
||||||
notification: Option<Rect>,
|
notification: Option<Rect>,
|
||||||
completion: Option<Rect>,
|
completion: Option<Rect>,
|
||||||
@@ -5822,6 +6032,7 @@ impl WheelPanels {
|
|||||||
}
|
}
|
||||||
self.hover = None;
|
self.hover = None;
|
||||||
self.caret = None;
|
self.caret = None;
|
||||||
|
self.cursor = None;
|
||||||
self.input = None;
|
self.input = None;
|
||||||
self.notification = None;
|
self.notification = None;
|
||||||
self.completion = None;
|
self.completion = None;
|
||||||
@@ -6357,7 +6568,11 @@ fn draw_file_list(
|
|||||||
&& (!browsing || picker.entries().len() == 0 || results_area.height == 0)
|
&& (!browsing || picker.entries().len() == 0 || results_area.height == 0)
|
||||||
{
|
{
|
||||||
let cursor_x = if browsing { 0 } else { query_width - offset };
|
let cursor_x = if browsing { 0 } else { query_width - offset };
|
||||||
frame.set_cursor_position((query_area.x + cursor_x as u16, query_area.y));
|
set_cursor(
|
||||||
|
frame,
|
||||||
|
panels,
|
||||||
|
Position::new(query_area.x + cursor_x as u16, query_area.y),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if let Some(root) = root {
|
if let Some(root) = root {
|
||||||
frame.render_widget(root, root_area);
|
frame.render_widget(root, root_area);
|
||||||
@@ -6488,10 +6703,14 @@ fn draw_file_list(
|
|||||||
&& selected_index >= first
|
&& selected_index >= first
|
||||||
&& selected_index - first < count
|
&& selected_index - first < count
|
||||||
{
|
{
|
||||||
frame.set_cursor_position((
|
set_cursor(
|
||||||
results_area.x,
|
frame,
|
||||||
results_area.y + (selected_index - first) as u16,
|
panels,
|
||||||
));
|
Position::new(
|
||||||
|
results_area.x,
|
||||||
|
results_area.y + (selected_index - first) as u16,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !show_help {
|
if !show_help {
|
||||||
@@ -6587,7 +6806,11 @@ fn draw_palette(
|
|||||||
.style(Style::default().fg(Color::Yellow)),
|
.style(Style::default().fg(Color::Yellow)),
|
||||||
query_area,
|
query_area,
|
||||||
);
|
);
|
||||||
frame.set_cursor_position((query_area.x + (query_width - offset) as u16, query_area.y));
|
set_cursor(
|
||||||
|
frame,
|
||||||
|
panels,
|
||||||
|
Position::new(query_area.x + (query_width - offset) as u16, query_area.y),
|
||||||
|
);
|
||||||
if palette.entries.is_empty() {
|
if palette.entries.is_empty() {
|
||||||
panels.picker_scroll.palette = None;
|
panels.picker_scroll.palette = None;
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
|
|||||||
@@ -629,10 +629,6 @@ fn branch_changes_default_and_mode_switches_compare_the_selected_divergent_tips(
|
|||||||
session.editor().diff().unwrap().comparison_mode,
|
session.editor().diff().unwrap().comparison_mode,
|
||||||
ComparisonMode::Snapshots
|
ComparisonMode::Snapshots
|
||||||
);
|
);
|
||||||
assert_eq!(
|
|
||||||
session.editor().message(),
|
|
||||||
"Working checkout requires Snapshots"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -1367,6 +1367,31 @@ fn jj_private_fetch_discovers_never_fetched_bookmark_and_searchable_remote_chang
|
|||||||
);
|
);
|
||||||
assert_eq!(changed(&comparison, Path::new("file")).after, ["local"]);
|
assert_eq!(changed(&comparison, Path::new("file")).after, ["local"]);
|
||||||
let loaded = repository.fetch_remote("origin").unwrap();
|
let loaded = repository.fetch_remote("origin").unwrap();
|
||||||
|
assert_eq!(loaded.graph[&tip].parents, [ancestor.clone()]);
|
||||||
|
assert_eq!(
|
||||||
|
loaded.graph[&ancestor].description,
|
||||||
|
"remote historic needle"
|
||||||
|
);
|
||||||
|
assert!(!loaded.graph[&tip].current);
|
||||||
|
let positions: std::collections::BTreeMap<_, _> = loaded
|
||||||
|
.entries
|
||||||
|
.iter()
|
||||||
|
.filter(|entry| entry.category == Category::Revisions)
|
||||||
|
.enumerate()
|
||||||
|
.filter_map(|(index, entry)| match &entry.target {
|
||||||
|
RevisionTarget::Commit(id) => Some((id.as_str(), index)),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
for (id, metadata) in &loaded.graph {
|
||||||
|
for parent in &metadata.parents {
|
||||||
|
if let (Some(child), Some(parent)) =
|
||||||
|
(positions.get(id.as_str()), positions.get(parent.as_str()))
|
||||||
|
{
|
||||||
|
assert!(child < parent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
let historic = loaded
|
let historic = loaded
|
||||||
.entries
|
.entries
|
||||||
.iter()
|
.iter()
|
||||||
|
|||||||
+54
-1
@@ -115,6 +115,17 @@ fn cli_deletes_only_the_requested_comment_and_preserves_failed_targets() {
|
|||||||
assert!(empty.comments.is_empty());
|
assert!(empty.comments.is_empty());
|
||||||
assert_eq!(empty.storage_revision, loaded.storage_revision + 1);
|
assert_eq!(empty.storage_revision, loaded.storage_revision + 1);
|
||||||
assert_eq!(empty.summary, review.summary);
|
assert_eq!(empty.summary, review.summary);
|
||||||
|
let output = cli("repo", &["list", "--json"]);
|
||||||
|
assert!(output.status.success(), "{:?}", output);
|
||||||
|
let list: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
|
||||||
|
assert_eq!(list["reviews"], serde_json::json!([]));
|
||||||
|
let output = cli("repo", &["list"]);
|
||||||
|
assert!(output.status.success(), "{:?}", output);
|
||||||
|
assert!(
|
||||||
|
String::from_utf8(output.stdout)
|
||||||
|
.unwrap()
|
||||||
|
.contains("No stored reviews.")
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
store
|
store
|
||||||
.load(&other.root, other.storage_id.unwrap())
|
.load(&other.root, other.storage_id.unwrap())
|
||||||
@@ -154,6 +165,7 @@ fn cli_resolves_only_the_requested_comment_and_preserves_original_feedback() {
|
|||||||
let storage = root.join("reviews");
|
let storage = root.join("reviews");
|
||||||
let mut store = Store::open(&storage).unwrap();
|
let mut store = Store::open(&storage).unwrap();
|
||||||
let mut review = Review::new(root.join("repo"));
|
let mut review = Review::new(root.join("repo"));
|
||||||
|
review.comparison = Some(["frozen-base".into(), "working:frozen-tip".into()]);
|
||||||
review.summary = "Keep summary".into();
|
review.summary = "Keep summary".into();
|
||||||
review.verdict = Some(Verdict::ChangesRequested);
|
review.verdict = Some(Verdict::ChangesRequested);
|
||||||
review.comments = [u64::MAX, 2, 3]
|
review.comments = [u64::MAX, 2, 3]
|
||||||
@@ -176,6 +188,13 @@ fn cli_resolves_only_the_requested_comment_and_preserves_original_feedback() {
|
|||||||
let mut other = Review::new(root.join("other"));
|
let mut other = Review::new(root.join("other"));
|
||||||
other.comments = review.comments.clone();
|
other.comments = review.comments.clone();
|
||||||
store.save(&mut other).unwrap();
|
store.save(&mut other).unwrap();
|
||||||
|
let mut source = Review::new(review.root.clone());
|
||||||
|
source.summary = "Progress without comments".into();
|
||||||
|
source.verdict = Some(Verdict::LooksGood);
|
||||||
|
store.save(&mut source).unwrap();
|
||||||
|
let mut reversed = Review::new(review.root.clone());
|
||||||
|
reversed.comparison = Some(["working:frozen-tip".into(), "frozen-base".into()]);
|
||||||
|
store.save(&mut reversed).unwrap();
|
||||||
let id = review.storage_id.unwrap();
|
let id = review.storage_id.unwrap();
|
||||||
let id_text = id.to_string();
|
let id_text = id.to_string();
|
||||||
let cli = |repo: &str, args: &[&str]| {
|
let cli = |repo: &str, args: &[&str]| {
|
||||||
@@ -214,6 +233,7 @@ fn cli_resolves_only_the_requested_comment_and_preserves_original_feedback() {
|
|||||||
let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
|
let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
|
||||||
assert_eq!(json["id"], id);
|
assert_eq!(json["id"], id);
|
||||||
assert_eq!(json["revision"], review.storage_revision + 1);
|
assert_eq!(json["revision"], review.storage_revision + 1);
|
||||||
|
assert_eq!(json["comparison"], serde_json::json!(review.comparison));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
json["counts"],
|
json["counts"],
|
||||||
serde_json::json!({"total": 3, "resolved": 2, "unresolved": 1})
|
serde_json::json!({"total": 3, "resolved": 2, "unresolved": 1})
|
||||||
@@ -227,6 +247,7 @@ fn cli_resolves_only_the_requested_comment_and_preserves_original_feedback() {
|
|||||||
assert_eq!(loaded.summary, review.summary);
|
assert_eq!(loaded.summary, review.summary);
|
||||||
assert_eq!(loaded.verdict, review.verdict);
|
assert_eq!(loaded.verdict, review.verdict);
|
||||||
assert_eq!(loaded.reviewed_files, review.reviewed_files);
|
assert_eq!(loaded.reviewed_files, review.reviewed_files);
|
||||||
|
assert_eq!(loaded.comparison, review.comparison);
|
||||||
assert!(store.save(&mut review).is_err()); // Stale editors cannot undo resolution.
|
assert!(store.save(&mut review).is_err()); // Stale editors cannot undo resolution.
|
||||||
assert_eq!(store.load(&review.root, id).unwrap().comments, expected);
|
assert_eq!(store.load(&review.root, id).unwrap().comments, expected);
|
||||||
|
|
||||||
@@ -264,8 +285,32 @@ fn cli_resolves_only_the_requested_comment_and_preserves_original_feedback() {
|
|||||||
let output = cli("repo", &["list", "--json"]);
|
let output = cli("repo", &["list", "--json"]);
|
||||||
assert!(output.status.success(), "{:?}", output);
|
assert!(output.status.success(), "{:?}", output);
|
||||||
let list: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
|
let list: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
|
||||||
assert_eq!(list["reviews"].as_array().unwrap().len(), 1);
|
assert_eq!(
|
||||||
|
list["reviews"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|review| review["id"].as_i64().unwrap())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec![id]
|
||||||
|
);
|
||||||
|
assert_eq!(list["reviews"][0]["comparison"], json["comparison"]);
|
||||||
assert_eq!(list["reviews"][0]["counts"], json["counts"]);
|
assert_eq!(list["reviews"][0]["counts"], json["counts"]);
|
||||||
|
let output = cli("repo", &["list"]);
|
||||||
|
assert!(output.status.success(), "{:?}", output);
|
||||||
|
let text = String::from_utf8(output.stdout).unwrap();
|
||||||
|
assert!(!text.contains("Progress without comments"));
|
||||||
|
assert!(text.contains("Scope: comparison frozen-base → working:frozen-tip"));
|
||||||
|
assert!(!text.contains("Scope: comparison working:frozen-tip → frozen-base"));
|
||||||
|
assert_eq!(fs::read(&path).unwrap(), before);
|
||||||
|
let output = cli(
|
||||||
|
"repo",
|
||||||
|
&["show", &source.storage_id.unwrap().to_string(), "--json"],
|
||||||
|
);
|
||||||
|
assert!(output.status.success(), "{:?}", output);
|
||||||
|
let shown: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
|
||||||
|
assert_eq!(shown["summary"], source.summary);
|
||||||
|
assert_eq!(shown["verdict"], "looks_good");
|
||||||
let output = cli("repo", &["resolve", &id_text, "2"]);
|
let output = cli("repo", &["resolve", &id_text, "2"]);
|
||||||
assert!(output.status.success(), "{:?}", output);
|
assert!(output.status.success(), "{:?}", output);
|
||||||
let text = String::from_utf8(output.stdout).unwrap();
|
let text = String::from_utf8(output.stdout).unwrap();
|
||||||
@@ -273,6 +318,14 @@ fn cli_resolves_only_the_requested_comment_and_preserves_original_feedback() {
|
|||||||
let loaded = store.load(&review.root, id).unwrap();
|
let loaded = store.load(&review.root, id).unwrap();
|
||||||
assert_eq!(loaded.storage_revision, review.storage_revision + 2);
|
assert_eq!(loaded.storage_revision, review.storage_revision + 2);
|
||||||
assert!(loaded.comments.iter().all(|comment| comment.resolved));
|
assert!(loaded.comments.iter().all(|comment| comment.resolved));
|
||||||
|
let output = cli("repo", &["list", "--json"]);
|
||||||
|
assert!(output.status.success(), "{:?}", output);
|
||||||
|
let list: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
|
||||||
|
assert_eq!(list["reviews"][0]["id"], id);
|
||||||
|
assert_eq!(
|
||||||
|
list["reviews"][0]["counts"],
|
||||||
|
serde_json::json!({"total": 3, "resolved": 3, "unresolved": 0})
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
store
|
store
|
||||||
.load(&other.root, other.storage_id.unwrap())
|
.load(&other.root, other.storage_id.unwrap())
|
||||||
|
|||||||
+186
-18
@@ -19,7 +19,8 @@ fn conflict_history_preserves_source_gaps_base_changes_and_native_eof_rows() {
|
|||||||
assert_eq!(
|
assert_eq!(
|
||||||
rows,
|
rows,
|
||||||
[
|
[
|
||||||
[Some(0), Some(0), Some(0)],
|
[None, Some(0), None],
|
||||||
|
[Some(0), None, Some(0)],
|
||||||
[None, None, Some(1)],
|
[None, None, Some(1)],
|
||||||
[Some(1), Some(1), Some(2)],
|
[Some(1), Some(1), Some(2)],
|
||||||
[Some(2), Some(2), Some(3)],
|
[Some(2), Some(2), Some(3)],
|
||||||
@@ -44,13 +45,13 @@ fn conflict_history_preserves_source_gaps_base_changes_and_native_eof_rows() {
|
|||||||
assert!(line.changes().any(|range| range.contains(&column)));
|
assert!(line.changes().any(|range| range.contains(&column)));
|
||||||
}
|
}
|
||||||
assert!(!line.changes().any(|range| range.contains(&4)));
|
assert!(!line.changes().any(|range| range.contains(&4)));
|
||||||
let ColumnRow::Text(line) = base.row(2).unwrap() else {
|
let ColumnRow::Text(line) = base.row(3).unwrap() else {
|
||||||
panic!("unchanged base row must contain text");
|
panic!("unchanged base row must contain text");
|
||||||
};
|
};
|
||||||
assert!(!line.changed);
|
assert!(!line.changed);
|
||||||
assert_eq!(line.changes().count(), 0);
|
assert_eq!(line.changes().count(), 0);
|
||||||
let right = history.column(2);
|
let right = history.column(2);
|
||||||
let ColumnRow::Text(line) = right.view().row(1).unwrap() else {
|
let ColumnRow::Text(line) = right.view().row(2).unwrap() else {
|
||||||
panic!("inserted right row must contain text");
|
panic!("inserted right row must contain text");
|
||||||
};
|
};
|
||||||
assert_eq!(line.text, Some("added"));
|
assert_eq!(line.text, Some("added"));
|
||||||
@@ -62,9 +63,45 @@ fn conflict_history_preserves_source_gaps_base_changes_and_native_eof_rows() {
|
|||||||
"abc deg\nadded\nkeep\n",
|
"abc deg\nadded\nkeep\n",
|
||||||
"xbc def\nkeep\n",
|
"xbc def\nkeep\n",
|
||||||
]);
|
]);
|
||||||
assert_eq!(history.column(1).view().native_row(1), Some(1));
|
assert_eq!(history.column(1).view().native_row(2), Some(1));
|
||||||
assert_eq!(history.column(0).view().native_row(1), None);
|
assert_eq!(history.column(0).view().native_row(2), None);
|
||||||
assert_eq!(history.column(2).view().native_row(1), None);
|
assert_eq!(history.column(2).view().native_row(2), None);
|
||||||
|
// Deletions are visible on either side, but never become editable source rows.
|
||||||
|
let history = HistoryComparison::new(["keep\n", "old\nkeep\n", "new\nkeep\n"]);
|
||||||
|
for column in [0, 2] {
|
||||||
|
let projection = history.column(column).view();
|
||||||
|
let ColumnRow::Text(line) = projection.row(0).unwrap() else {
|
||||||
|
panic!("removed base text must remain visible");
|
||||||
|
};
|
||||||
|
assert_eq!(line.text, Some("old"));
|
||||||
|
assert!(line.changed);
|
||||||
|
assert_eq!(line.polarity, Focus::Before);
|
||||||
|
assert!(!line.native);
|
||||||
|
assert_eq!(projection.native_row(0), None);
|
||||||
|
assert_eq!(projection.text_row(0), Some((usize::MAX, "old")));
|
||||||
|
}
|
||||||
|
assert!(matches!(
|
||||||
|
history.column(0).view().row(1),
|
||||||
|
Some(ColumnRow::Gap)
|
||||||
|
));
|
||||||
|
let right = history.column(2).view();
|
||||||
|
let ColumnRow::Text(line) = right.row(1).unwrap() else {
|
||||||
|
panic!("replacement must be an addition");
|
||||||
|
};
|
||||||
|
assert_eq!(line.text, Some("new"));
|
||||||
|
assert_eq!(line.polarity, Focus::After);
|
||||||
|
assert_eq!(right.native_row(1), Some(0));
|
||||||
|
assert_eq!(right.display_row(0), Some(1));
|
||||||
|
let history = HistoryComparison::new(["left", "", "right"]);
|
||||||
|
for column in [0, 2] {
|
||||||
|
let projection = history.column(column).view();
|
||||||
|
let ColumnRow::Text(line) = projection.row(0).unwrap() else {
|
||||||
|
panic!("add/add without a base must show additions on both sides");
|
||||||
|
};
|
||||||
|
assert!(line.changed && line.native);
|
||||||
|
assert_eq!(line.polarity, Focus::After);
|
||||||
|
assert_eq!(projection.native_row(0), Some(0));
|
||||||
|
}
|
||||||
for text in ["", "\n", "same", "same\n"] {
|
for text in ["", "\n", "same", "same\n"] {
|
||||||
let history = HistoryComparison::new([text; 3]);
|
let history = HistoryComparison::new([text; 3]);
|
||||||
let native_rows = text.split('\n').count();
|
let native_rows = text.split('\n').count();
|
||||||
@@ -83,7 +120,7 @@ fn resolver_equal_text_retains_native_rows_and_cached_viewport() {
|
|||||||
for text in ["", "\n", "same\n", "same"] {
|
for text in ["", "\n", "same\n", "same"] {
|
||||||
let lines: Vec<_> = text.split('\n').map(str::to_owned).collect();
|
let lines: Vec<_> = text.split('\n').map(str::to_owned).collect();
|
||||||
let mut state = DiffState::new();
|
let mut state = DiffState::new();
|
||||||
state.set_resolver_text(&lines, &lines);
|
state.set_resolver_text(&lines, &lines, None);
|
||||||
assert_eq!(state.pane_layout(), PaneLayout::Split);
|
assert_eq!(state.pane_layout(), PaneLayout::Split);
|
||||||
assert!(state.comparison.as_ref().unwrap().files[0].hunks.is_empty());
|
assert!(state.comparison.as_ref().unwrap().files[0].hunks.is_empty());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -99,7 +136,7 @@ fn resolver_equal_text_retains_native_rows_and_cached_viewport() {
|
|||||||
state.top = 1;
|
state.top = 1;
|
||||||
state.left_before = 3;
|
state.left_before = 3;
|
||||||
state.left_after = 4;
|
state.left_after = 4;
|
||||||
state.set_resolver_text(&lines, &lines);
|
state.set_resolver_text(&lines, &lines, None);
|
||||||
assert_eq!(state.comparison_id, id);
|
assert_eq!(state.comparison_id, id);
|
||||||
assert_eq!((state.top, state.left_before, state.left_after), (1, 3, 4));
|
assert_eq!((state.top, state.left_before, state.left_after), (1, 3, 4));
|
||||||
}
|
}
|
||||||
@@ -357,6 +394,7 @@ fn revision(
|
|||||||
fn catalog() -> Catalog {
|
fn catalog() -> Catalog {
|
||||||
Catalog {
|
Catalog {
|
||||||
kind: RepoKind::Git,
|
kind: RepoKind::Git,
|
||||||
|
graph: Default::default(),
|
||||||
entries: vec![
|
entries: vec![
|
||||||
revision(
|
revision(
|
||||||
RevisionTarget::Working,
|
RevisionTarget::Working,
|
||||||
@@ -1536,7 +1574,7 @@ fn revision_matches_rank_later_contiguous_boundaries_and_tight_subsequences() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn jujutsu_fuzzy_selectors_rank_labels_above_descriptions_in_both_stages() {
|
fn jujutsu_fuzzy_selectors_preserve_catalog_order_in_both_stages() {
|
||||||
for phase in [Phase::Before, Phase::After] {
|
for phase in [Phase::Before, Phase::After] {
|
||||||
for (category_index, &category) in RepoKind::Jujutsu.categories().iter().enumerate() {
|
for (category_index, &category) in RepoKind::Jujutsu.categories().iter().enumerate() {
|
||||||
let mut state = DiffState::new();
|
let mut state = DiffState::new();
|
||||||
@@ -1544,6 +1582,7 @@ fn jujutsu_fuzzy_selectors_rank_labels_above_descriptions_in_both_stages() {
|
|||||||
1,
|
1,
|
||||||
Ok(Outcome::Catalog(Catalog {
|
Ok(Outcome::Catalog(Catalog {
|
||||||
kind: RepoKind::Jujutsu,
|
kind: RepoKind::Jujutsu,
|
||||||
|
graph: Default::default(),
|
||||||
entries: vec![
|
entries: vec![
|
||||||
revision(
|
revision(
|
||||||
RevisionTarget::Working,
|
RevisionTarget::Working,
|
||||||
@@ -1584,15 +1623,110 @@ fn jujutsu_fuzzy_selectors_rank_labels_above_descriptions_in_both_stages() {
|
|||||||
.map(|entry| &entry.target)
|
.map(|entry| &entry.target)
|
||||||
.collect::<Vec<_>>(),
|
.collect::<Vec<_>>(),
|
||||||
[
|
[
|
||||||
&RevisionTarget::Commit("immutable-exact-target".into()),
|
|
||||||
&RevisionTarget::Commit("immutable-fuzzy-target".into()),
|
|
||||||
&RevisionTarget::Commit("description-target".into()),
|
&RevisionTarget::Commit("description-target".into()),
|
||||||
|
&RevisionTarget::Commit("immutable-fuzzy-target".into()),
|
||||||
|
&RevisionTarget::Commit("immutable-exact-target".into()),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn graph_state() -> DiffState {
|
||||||
|
let mut catalog = Catalog {
|
||||||
|
kind: RepoKind::Jujutsu,
|
||||||
|
graph: Default::default(),
|
||||||
|
notice: None,
|
||||||
|
entries: vec![revision(
|
||||||
|
RevisionTarget::Working,
|
||||||
|
"Working",
|
||||||
|
"",
|
||||||
|
Category::Revisions,
|
||||||
|
)],
|
||||||
|
};
|
||||||
|
for (id, parents) in [
|
||||||
|
("merge", vec!["left", "right"]),
|
||||||
|
("left", vec!["hidden"]),
|
||||||
|
("hidden", vec!["root"]),
|
||||||
|
("right", vec!["root"]),
|
||||||
|
("root", vec![]),
|
||||||
|
] {
|
||||||
|
catalog.entries.push(revision(
|
||||||
|
RevisionTarget::Commit(id.into()),
|
||||||
|
id,
|
||||||
|
"needle",
|
||||||
|
Category::Revisions,
|
||||||
|
));
|
||||||
|
catalog.graph.insert(
|
||||||
|
id.into(),
|
||||||
|
RevisionGraph {
|
||||||
|
parents: parents.into_iter().map(String::from).collect(),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
catalog.entries.push(revision(
|
||||||
|
RevisionTarget::Bookmark {
|
||||||
|
name: "topic".into(),
|
||||||
|
remote: None,
|
||||||
|
},
|
||||||
|
"topic",
|
||||||
|
"change merge needle",
|
||||||
|
Category::Bookmarks,
|
||||||
|
));
|
||||||
|
catalog.entries.push(revision(
|
||||||
|
RevisionTarget::Bookmark {
|
||||||
|
name: "base".into(),
|
||||||
|
remote: None,
|
||||||
|
},
|
||||||
|
"base",
|
||||||
|
"change root needle",
|
||||||
|
Category::Bookmarks,
|
||||||
|
));
|
||||||
|
let mut state = DiffState::new();
|
||||||
|
state.apply_result(1, Ok(Outcome::Catalog(catalog)));
|
||||||
|
state
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn before_identity_survives_query_and_category_without_duplicate_graph_nodes() {
|
||||||
|
let mut state = graph_state();
|
||||||
|
state.category = 1;
|
||||||
|
state.refilter();
|
||||||
|
state.selected = 1;
|
||||||
|
state.choose();
|
||||||
|
let before = state.before.clone().unwrap();
|
||||||
|
assert!(matches!(before.target, RevisionTarget::Bookmark { .. }));
|
||||||
|
state.category = 0;
|
||||||
|
state.refilter();
|
||||||
|
assert_eq!(
|
||||||
|
state
|
||||||
|
.entries()
|
||||||
|
.filter(|entry| state
|
||||||
|
.graph_revision(entry)
|
||||||
|
.is_some_and(|(id, _)| id == "merge"))
|
||||||
|
.count(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
for category in 0..2 {
|
||||||
|
state.category = category;
|
||||||
|
state.query = "unmatched".into();
|
||||||
|
state.refilter();
|
||||||
|
assert_eq!(state.entries().len(), 2);
|
||||||
|
let retained = state
|
||||||
|
.entries()
|
||||||
|
.find(|entry| entry.target == before.target)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(retained.target, before.target);
|
||||||
|
assert_eq!(state.before.as_ref(), Some(&before));
|
||||||
|
state.selected = 1;
|
||||||
|
assert_eq!(
|
||||||
|
state.entries().nth(state.selected).unwrap().target,
|
||||||
|
before.target
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn selector_filters_literal_unicode_tokens_and_pins_working_in_each_category() {
|
fn selector_filters_literal_unicode_tokens_and_pins_working_in_each_category() {
|
||||||
let mut editor = editor();
|
let mut editor = editor();
|
||||||
@@ -1709,7 +1843,7 @@ fn loader_preserves_after_step_query_category_and_selection_then_compares() {
|
|||||||
state.entries().nth(state.selected).unwrap().target,
|
state.entries().nth(state.selected).unwrap().target,
|
||||||
RevisionTarget::FetchRemote("origin".into())
|
RevisionTarget::FetchRemote("origin".into())
|
||||||
);
|
);
|
||||||
assert_eq!(state.entries().len(), 3);
|
assert_eq!(state.entries().len(), 4);
|
||||||
let remote_position = state
|
let remote_position = state
|
||||||
.entries()
|
.entries()
|
||||||
.position(
|
.position(
|
||||||
@@ -2333,6 +2467,7 @@ fn live_state(before: Vec<String>, after: Vec<String>, eof: bool) -> DiffState {
|
|||||||
loaded: true,
|
loaded: true,
|
||||||
}],
|
}],
|
||||||
});
|
});
|
||||||
|
state.review_comparison = Some(["base".into(), "working:snapshot".into()]);
|
||||||
state.files = Some(FilePicker::from_paths(
|
state.files = Some(FilePicker::from_paths(
|
||||||
PathBuf::new(),
|
PathBuf::new(),
|
||||||
[(0, PathBuf::from("file"))],
|
[(0, PathBuf::from("file"))],
|
||||||
@@ -2407,7 +2542,7 @@ fn column_view_distinguishes_native_terminal_rows_from_missing_ordinary_text() {
|
|||||||
for text in ["", "same\n"] {
|
for text in ["", "same\n"] {
|
||||||
let lines: Vec<_> = text.split('\n').map(str::to_owned).collect();
|
let lines: Vec<_> = text.split('\n').map(str::to_owned).collect();
|
||||||
let mut state = DiffState::new();
|
let mut state = DiffState::new();
|
||||||
state.set_resolver_text(&lines, &lines);
|
state.set_resolver_text(&lines, &lines, None);
|
||||||
for focus in [Focus::Before, Focus::After] {
|
for focus in [Focus::Before, Focus::After] {
|
||||||
let view = state.column_view(focus).unwrap();
|
let view = state.column_view(focus).unwrap();
|
||||||
let native = lines.len() - 1;
|
let native = lines.len() - 1;
|
||||||
@@ -2727,7 +2862,33 @@ fn restored_review_marks_use_file_identities_and_rename_paths_not_catalog_order(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unchanged_file_review_survives_other_files_changing() {
|
fn review_marks_cannot_cross_comparison_scopes_even_with_matching_file_identities() {
|
||||||
|
let mut review = crate::review::ReviewState::default();
|
||||||
|
let mut state = DiffState::new();
|
||||||
|
let mut first = comparison();
|
||||||
|
first.files[0].review_revisions = Some(["file-before".into(), "file-after".into()]);
|
||||||
|
state.request_kind = Some(RequestKind::Comparison);
|
||||||
|
state.apply_result(1, Ok(Outcome::Comparison(first)));
|
||||||
|
state.toggle_reviewed(&mut review).unwrap();
|
||||||
|
let initial_scope = review.document.comparison.clone();
|
||||||
|
let mut second = comparison();
|
||||||
|
second.files[0].review_revisions = Some(["file-before".into(), "file-after".into()]);
|
||||||
|
second.revisions[1] = "another-head".into();
|
||||||
|
state.request_kind = Some(RequestKind::Comparison);
|
||||||
|
state.apply_result(2, Ok(Outcome::Comparison(second)));
|
||||||
|
state.restore_reviewed_files(&review);
|
||||||
|
assert!(state.reviewed_files.is_empty());
|
||||||
|
assert_eq!(
|
||||||
|
state.toggle_reviewed(&mut review),
|
||||||
|
Err("Select the review for this comparison first")
|
||||||
|
);
|
||||||
|
assert!(state.reviewed_files.is_empty());
|
||||||
|
assert_eq!(review.document.comparison, initial_scope);
|
||||||
|
assert_eq!(review.document.reviewed_files.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn working_refresh_retains_scope_and_unchanged_file_progress() {
|
||||||
let mut review = crate::review::ReviewState::default();
|
let mut review = crate::review::ReviewState::default();
|
||||||
use crate::repository::Repository;
|
use crate::repository::Repository;
|
||||||
use std::sync::{Arc, atomic::AtomicBool};
|
use std::sync::{Arc, atomic::AtomicBool};
|
||||||
@@ -2788,20 +2949,27 @@ fn unchanged_file_review_survives_other_files_changing() {
|
|||||||
.compare(2, &before, &after, ComparisonMode::Snapshots, &[])
|
.compare(2, &before, &after, ComparisonMode::Snapshots, &[])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_ne!(changes.revisions, original_revisions);
|
assert_ne!(changes.revisions, original_revisions);
|
||||||
state.request_kind = Some(RequestKind::Comparison);
|
state.after = Some(after);
|
||||||
state.apply_result(2, Ok(Outcome::Comparison(changes)));
|
state.request_kind = None;
|
||||||
|
state.pending = None;
|
||||||
|
state.loading = false;
|
||||||
|
state.queue_checkout_refresh(true);
|
||||||
|
state.take_request();
|
||||||
|
assert!(state.apply_result(2, Ok(Outcome::CheckoutRefresh(Some(changes)))));
|
||||||
|
assert_eq!(state.review_comparison, Some(original_revisions));
|
||||||
review.document = store.load(&root, id).unwrap();
|
review.document = store.load(&root, id).unwrap();
|
||||||
state.restore_reviewed_files(&review);
|
state.restore_reviewed_files(&review);
|
||||||
assert_eq!(state.reviewed_files, BTreeSet::from([0]));
|
assert_eq!(state.reviewed_files, BTreeSet::from([0]));
|
||||||
|
state.load_file(0);
|
||||||
let detail = repository.load_file(2, 0).unwrap();
|
let detail = repository.load_file(2, 0).unwrap();
|
||||||
state.apply_result(
|
assert!(state.apply_result(
|
||||||
3,
|
3,
|
||||||
Ok(Outcome::File {
|
Ok(Outcome::File {
|
||||||
comparison_id: 2,
|
comparison_id: 2,
|
||||||
file_index: 0,
|
file_index: 0,
|
||||||
file: detail,
|
file: detail,
|
||||||
}),
|
}),
|
||||||
);
|
));
|
||||||
state.restore_reviewed_files(&review);
|
state.restore_reviewed_files(&review);
|
||||||
assert_eq!(state.reviewed_files, BTreeSet::from([0]));
|
assert_eq!(state.reviewed_files, BTreeSet::from([0]));
|
||||||
std::fs::remove_dir_all(root).unwrap();
|
std::fs::remove_dir_all(root).unwrap();
|
||||||
|
|||||||
+331
-9
@@ -122,7 +122,10 @@ fn resolver_shares_native_excerpt_search_motions_and_explicit_writable_side() {
|
|||||||
}
|
}
|
||||||
let draft = before.join("\n");
|
let draft = before.join("\n");
|
||||||
let alternative = after.join("\n");
|
let alternative = after.join("\n");
|
||||||
let mut resolver = resolver_editor(&draft, &[(&draft, false), (&alternative, false)]);
|
let mut resolver = resolver_editor(
|
||||||
|
&draft,
|
||||||
|
&[(&draft, false), (&alternative, false), (&draft, true)],
|
||||||
|
);
|
||||||
let mut ordinary = editor();
|
let mut ordinary = editor();
|
||||||
historical_diff(&mut ordinary, &directory.0, before, after);
|
historical_diff(&mut ordinary, &directory.0, before, after);
|
||||||
for editor in [&mut resolver, &mut ordinary] {
|
for editor in [&mut resolver, &mut ordinary] {
|
||||||
@@ -161,10 +164,6 @@ fn resolver_shares_native_excerpt_search_motions_and_explicit_writable_side() {
|
|||||||
.is_some()
|
.is_some()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
assert_eq!(
|
|
||||||
resolver.diff().unwrap().display_rows,
|
|
||||||
ordinary.diff().unwrap().display_rows
|
|
||||||
);
|
|
||||||
assert!(resolver.diff_editing());
|
assert!(resolver.diff_editing());
|
||||||
assert!(resolver.diff().unwrap().side_editable(Focus::Before));
|
assert!(resolver.diff().unwrap().side_editable(Focus::Before));
|
||||||
assert!(!resolver.diff().unwrap().side_editable(Focus::After));
|
assert!(!resolver.diff().unwrap().side_editable(Focus::After));
|
||||||
@@ -397,7 +396,6 @@ fn resolver_history_selects_every_side_and_base_without_moving_draft_or_viewport
|
|||||||
editor.set_diff_viewport(diff.comparison_id.unwrap(), diff.file, 8, 2, 4);
|
editor.set_diff_viewport(diff.comparison_id.unwrap(), diff.file, 8, 2, 4);
|
||||||
let cursor = editor.cursor();
|
let cursor = editor.cursor();
|
||||||
let buffer = editor.buffer_id();
|
let buffer = editor.buffer_id();
|
||||||
let id = editor.resolver_diff().unwrap().comparison_id;
|
|
||||||
assert!(editor.file_picker().is_none());
|
assert!(editor.file_picker().is_none());
|
||||||
editor.execute("word_wrap.toggle");
|
editor.execute("word_wrap.toggle");
|
||||||
assert!(editor.word_wrap());
|
assert!(editor.word_wrap());
|
||||||
@@ -419,7 +417,6 @@ fn resolver_history_selects_every_side_and_base_without_moving_draft_or_viewport
|
|||||||
assert_eq!(editor.cursor(), cursor);
|
assert_eq!(editor.cursor(), cursor);
|
||||||
assert_eq!(editor.buffer_id(), buffer);
|
assert_eq!(editor.buffer_id(), buffer);
|
||||||
let diff = editor.resolver_diff().unwrap();
|
let diff = editor.resolver_diff().unwrap();
|
||||||
assert_eq!(diff.comparison_id, id);
|
|
||||||
assert_eq!((diff.top, diff.left_before, diff.left_after), (8, 2, 4));
|
assert_eq!((diff.top, diff.left_before, diff.left_after), (8, 2, 4));
|
||||||
assert!(editor.word_wrap());
|
assert!(editor.word_wrap());
|
||||||
editor.handle(Input::Char('\t'));
|
editor.handle(Input::Char('\t'));
|
||||||
@@ -1839,6 +1836,7 @@ fn hidden_tab_diff_results_restore_visible_insert_interaction_and_route_request_
|
|||||||
let catalog = || {
|
let catalog = || {
|
||||||
Outcome::Catalog(Catalog {
|
Outcome::Catalog(Catalog {
|
||||||
kind: RepoKind::Git,
|
kind: RepoKind::Git,
|
||||||
|
graph: Default::default(),
|
||||||
entries: [
|
entries: [
|
||||||
RevisionTarget::Commit("base".into()),
|
RevisionTarget::Commit("base".into()),
|
||||||
RevisionTarget::Working,
|
RevisionTarget::Working,
|
||||||
@@ -2630,6 +2628,7 @@ fn diff_results_bind_the_owning_pane_without_disturbing_focused_document_or_mode
|
|||||||
id,
|
id,
|
||||||
Ok(Outcome::Catalog(Catalog {
|
Ok(Outcome::Catalog(Catalog {
|
||||||
kind: RepoKind::Git,
|
kind: RepoKind::Git,
|
||||||
|
graph: Default::default(),
|
||||||
entries: vec![Revision {
|
entries: vec![Revision {
|
||||||
target: RevisionTarget::Working,
|
target: RevisionTarget::Working,
|
||||||
label: "Working".into(),
|
label: "Working".into(),
|
||||||
@@ -3020,6 +3019,15 @@ fn diff_preserves_parked_buffers_visual_carets_and_insert_history() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn working_diff(editor: &mut Editor, root: &Path, paths: &[&str]) -> Box<[(PathBuf, String)]> {
|
fn working_diff(editor: &mut Editor, root: &Path, paths: &[&str]) -> Box<[(PathBuf, String)]> {
|
||||||
|
working_diff_with_revisions(editor, root, paths, ["base", "working-snapshot"])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn working_diff_with_revisions(
|
||||||
|
editor: &mut Editor,
|
||||||
|
root: &Path,
|
||||||
|
paths: &[&str],
|
||||||
|
revisions: [&str; 2],
|
||||||
|
) -> Box<[(PathBuf, String)]> {
|
||||||
use crate::diff::{Catalog, Category, ChangedFile, Comparison, FileStatus, RepoKind, Revision};
|
use crate::diff::{Catalog, Category, ChangedFile, Comparison, FileStatus, RepoKind, Revision};
|
||||||
editor.execute("diff");
|
editor.execute("diff");
|
||||||
let (id, _) = editor.take_diff_request().unwrap();
|
let (id, _) = editor.take_diff_request().unwrap();
|
||||||
@@ -3033,6 +3041,7 @@ fn working_diff(editor: &mut Editor, root: &Path, paths: &[&str]) -> Box<[(PathB
|
|||||||
id,
|
id,
|
||||||
Ok(Outcome::Catalog(Catalog {
|
Ok(Outcome::Catalog(Catalog {
|
||||||
kind: RepoKind::Git,
|
kind: RepoKind::Git,
|
||||||
|
graph: Default::default(),
|
||||||
entries: vec![
|
entries: vec![
|
||||||
revision(RevisionTarget::Commit("base".into()), "base"),
|
revision(RevisionTarget::Commit("base".into()), "base"),
|
||||||
revision(RevisionTarget::Working, "Working"),
|
revision(RevisionTarget::Working, "Working"),
|
||||||
@@ -3073,7 +3082,7 @@ fn working_diff(editor: &mut Editor, root: &Path, paths: &[&str]) -> Box<[(PathB
|
|||||||
Ok(Outcome::Comparison(Comparison {
|
Ok(Outcome::Comparison(Comparison {
|
||||||
blame: Default::default(),
|
blame: Default::default(),
|
||||||
root: root.canonicalize().unwrap(),
|
root: root.canonicalize().unwrap(),
|
||||||
revisions: ["base".into(), "working-snapshot".into()],
|
revisions: revisions.map(str::to_owned),
|
||||||
before: "base".into(),
|
before: "base".into(),
|
||||||
after: "Working".into(),
|
after: "Working".into(),
|
||||||
editable_paths: paths.iter().map(|path| PathBuf::from(*path)).collect(),
|
editable_paths: paths.iter().map(|path| PathBuf::from(*path)).collect(),
|
||||||
@@ -3084,6 +3093,16 @@ fn working_diff(editor: &mut Editor, root: &Path, paths: &[&str]) -> Box<[(PathB
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn historical_diff(editor: &mut Editor, root: &Path, before: Vec<String>, after: Vec<String>) {
|
fn historical_diff(editor: &mut Editor, root: &Path, before: Vec<String>, after: Vec<String>) {
|
||||||
|
historical_diff_with_revisions(editor, root, before, after, ["base", "head"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn historical_diff_with_revisions(
|
||||||
|
editor: &mut Editor,
|
||||||
|
root: &Path,
|
||||||
|
before: Vec<String>,
|
||||||
|
after: Vec<String>,
|
||||||
|
revisions: [&str; 2],
|
||||||
|
) {
|
||||||
use crate::diff::{Catalog, Category, ChangedFile, Comparison, FileStatus, RepoKind, Revision};
|
use crate::diff::{Catalog, Category, ChangedFile, Comparison, FileStatus, RepoKind, Revision};
|
||||||
editor.execute("diff");
|
editor.execute("diff");
|
||||||
let (id, _) = editor.take_diff_request().unwrap();
|
let (id, _) = editor.take_diff_request().unwrap();
|
||||||
@@ -3097,6 +3116,7 @@ fn historical_diff(editor: &mut Editor, root: &Path, before: Vec<String>, after:
|
|||||||
id,
|
id,
|
||||||
Ok(Outcome::Catalog(Catalog {
|
Ok(Outcome::Catalog(Catalog {
|
||||||
kind: RepoKind::Git,
|
kind: RepoKind::Git,
|
||||||
|
graph: Default::default(),
|
||||||
entries: vec![revision("base"), revision("head")],
|
entries: vec![revision("base"), revision("head")],
|
||||||
notice: None,
|
notice: None,
|
||||||
})),
|
})),
|
||||||
@@ -3111,7 +3131,7 @@ fn historical_diff(editor: &mut Editor, root: &Path, before: Vec<String>, after:
|
|||||||
Ok(Outcome::Comparison(Comparison {
|
Ok(Outcome::Comparison(Comparison {
|
||||||
blame: Default::default(),
|
blame: Default::default(),
|
||||||
root: root.to_owned(),
|
root: root.to_owned(),
|
||||||
revisions: ["base".into(), "head".into()],
|
revisions: revisions.map(str::to_owned),
|
||||||
before: "base".into(),
|
before: "base".into(),
|
||||||
after: "head".into(),
|
after: "head".into(),
|
||||||
editable_paths: Default::default(),
|
editable_paths: Default::default(),
|
||||||
@@ -3157,6 +3177,305 @@ fn source_review_editor(directory: &Directory, text: &str) -> (Editor, PathBuf,
|
|||||||
(editor, root, path)
|
(editor, root, path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn comparison_reviews_use_ordered_resolved_identities_and_resume_only_the_same_pair() {
|
||||||
|
use crate::review::{Store, Verdict};
|
||||||
|
let directory = Directory::new();
|
||||||
|
let (mut editor, root, _) = source_review_editor(&directory, "source\n");
|
||||||
|
historical_diff(&mut editor, &root, vec!["old".into()], vec!["new".into()]);
|
||||||
|
editor.execute("comment");
|
||||||
|
type_text(&mut editor, "iPair feedback");
|
||||||
|
editor.handle(Input::Escape);
|
||||||
|
editor.execute("w");
|
||||||
|
editor.handle(Input::Escape);
|
||||||
|
editor.execute("diff.review.toggle");
|
||||||
|
editor.execute("review.verdict looks_good");
|
||||||
|
let original = editor.local_review.document.clone();
|
||||||
|
editor.handle(Input::Escape);
|
||||||
|
for pair in [["other", "head"], ["base", "other"], ["head", "base"]] {
|
||||||
|
historical_diff_with_revisions(
|
||||||
|
&mut editor,
|
||||||
|
&root,
|
||||||
|
vec!["old".into()],
|
||||||
|
vec!["new".into()],
|
||||||
|
pair,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
editor.local_review.document.comparison,
|
||||||
|
Some(pair.map(str::to_owned))
|
||||||
|
);
|
||||||
|
assert!(editor.local_review.document.comments.is_empty());
|
||||||
|
assert!(editor.local_review.document.verdict.is_none());
|
||||||
|
assert!(editor.local_review.document.reviewed_files.is_empty());
|
||||||
|
assert!(editor.workspace_diff().unwrap().reviewed_files.is_empty());
|
||||||
|
editor.execute("review.verdict changes_requested");
|
||||||
|
assert_ne!(editor.local_review.document.storage_id, original.storage_id);
|
||||||
|
editor.handle(Input::Escape);
|
||||||
|
}
|
||||||
|
historical_diff(&mut editor, &root, vec!["old".into()], vec!["new".into()]);
|
||||||
|
assert_eq!(editor.local_review.document.storage_id, original.storage_id);
|
||||||
|
assert_eq!(
|
||||||
|
editor.local_review.document.comments[0].body,
|
||||||
|
"Pair feedback"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
editor.local_review.document.verdict,
|
||||||
|
Some(Verdict::LooksGood)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
editor.workspace_diff().unwrap().reviewed_files,
|
||||||
|
BTreeSet::from([0])
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Store::open(&root.join("reviews"))
|
||||||
|
.unwrap()
|
||||||
|
.list(&root)
|
||||||
|
.unwrap()
|
||||||
|
.len(),
|
||||||
|
4
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn source_and_comparison_reviews_remain_separate_in_shared_panes_and_tabs() {
|
||||||
|
let directory = Directory::new();
|
||||||
|
let (mut editor, root, _) = source_review_editor(&directory, "source\n");
|
||||||
|
let source = editor.pane_id;
|
||||||
|
editor.execute("comment");
|
||||||
|
type_text(&mut editor, "iSource feedback");
|
||||||
|
editor.handle(Input::Escape);
|
||||||
|
editor.execute("w");
|
||||||
|
let source_id = editor.local_review.document.storage_id;
|
||||||
|
editor.handle(Input::Escape);
|
||||||
|
editor.split_pane(SplitAxis::Vertical);
|
||||||
|
let comparison_pane = editor.pane_id;
|
||||||
|
historical_diff(&mut editor, &root, vec!["old".into()], vec!["new".into()]);
|
||||||
|
editor.execute("comment");
|
||||||
|
type_text(&mut editor, "iComparison feedback");
|
||||||
|
editor.handle(Input::Escape);
|
||||||
|
editor.execute("w");
|
||||||
|
let comparison_id = editor.local_review.document.storage_id;
|
||||||
|
assert_ne!(comparison_id, source_id);
|
||||||
|
editor.handle(Input::Escape);
|
||||||
|
editor.select_pane(source);
|
||||||
|
editor.execute("review.edit 1");
|
||||||
|
assert_eq!(editor.local_review.document.storage_id, source_id);
|
||||||
|
assert!(editor.local_review.document.comparison.is_none());
|
||||||
|
assert_eq!(editor.context().text, "Source feedback");
|
||||||
|
editor.handle(Input::Escape);
|
||||||
|
editor.select_pane(comparison_pane);
|
||||||
|
editor.execute("review.edit 1");
|
||||||
|
assert_eq!(editor.local_review.document.storage_id, comparison_id);
|
||||||
|
assert_eq!(editor.context().text, "Comparison feedback");
|
||||||
|
editor.handle(Input::Escape);
|
||||||
|
editor.select_pane(source);
|
||||||
|
editor.execute("tab.new");
|
||||||
|
editor.execute("review.edit 1");
|
||||||
|
assert_eq!(editor.local_review.document.storage_id, source_id);
|
||||||
|
assert_eq!(editor.context().text, "Source feedback");
|
||||||
|
editor.handle(Input::Escape);
|
||||||
|
historical_diff(&mut editor, &root, vec!["old".into()], vec!["new".into()]);
|
||||||
|
assert_eq!(editor.local_review.document.storage_id, comparison_id);
|
||||||
|
editor.execute("tab.previous");
|
||||||
|
editor.select_pane(comparison_pane);
|
||||||
|
editor.execute("review.edit 1");
|
||||||
|
assert_eq!(editor.context().text, "Comparison feedback");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn review_selectors_and_explicit_opens_reject_other_comparison_and_source_scopes() {
|
||||||
|
use crate::review::{Review, Store};
|
||||||
|
let directory = Directory::new();
|
||||||
|
let (mut editor, root, _) = source_review_editor(&directory, "source\n");
|
||||||
|
let mut store = Store::open(&root.join("reviews")).unwrap();
|
||||||
|
let mut source = Review::new(root.clone());
|
||||||
|
source.summary = "source-only".into();
|
||||||
|
store.save(&mut source).unwrap();
|
||||||
|
let mut other = Review::new(root.clone());
|
||||||
|
other.comparison = Some(["different".into(), "head".into()]);
|
||||||
|
other.summary = "other-pair-only".into();
|
||||||
|
store.save(&mut other).unwrap();
|
||||||
|
let other_json = root.join("other.json");
|
||||||
|
other.save(&other_json).unwrap();
|
||||||
|
let source_json = root.join("source.json");
|
||||||
|
source.save(&source_json).unwrap();
|
||||||
|
historical_diff(&mut editor, &root, vec!["old".into()], vec!["new".into()]);
|
||||||
|
editor.execute("review.verdict looks_good");
|
||||||
|
let current = editor.local_review.document.clone();
|
||||||
|
for command in [
|
||||||
|
format!("review.open {}", source.storage_id.unwrap()),
|
||||||
|
format!("review.open {}", other.storage_id.unwrap()),
|
||||||
|
format!("review.open {}", source_json.display()),
|
||||||
|
format!("review.open {}", other_json.display()),
|
||||||
|
] {
|
||||||
|
editor.execute(&command);
|
||||||
|
assert!(
|
||||||
|
editor
|
||||||
|
.message()
|
||||||
|
.contains("different comparison or source scope")
|
||||||
|
);
|
||||||
|
assert_eq!(editor.local_review.document.storage_id, current.storage_id);
|
||||||
|
assert_eq!(editor.local_review.document.comparison, current.comparison);
|
||||||
|
assert_eq!(editor.local_review.document.verdict, current.verdict);
|
||||||
|
}
|
||||||
|
editor.execute("review.new");
|
||||||
|
let second_id = editor.local_review.document.storage_id;
|
||||||
|
editor.execute("review.open");
|
||||||
|
assert!(!editor.context().text.contains("source-only"));
|
||||||
|
assert!(!editor.context().text.contains("other-pair-only"));
|
||||||
|
editor.handle(Input::Enter);
|
||||||
|
assert_eq!(editor.local_review.document.storage_id, second_id);
|
||||||
|
editor.execute(&format!("review.open {}", current.storage_id.unwrap()));
|
||||||
|
assert_eq!(editor.local_review.document.storage_id, current.storage_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dirty_review_drafts_guard_scope_replacement_new_reviews_and_mode_changes() {
|
||||||
|
let directory = Directory::new();
|
||||||
|
let (mut editor, root, _) = source_review_editor(&directory, "source\n");
|
||||||
|
let source = editor.pane_id;
|
||||||
|
editor.split_pane(SplitAxis::Vertical);
|
||||||
|
let comparison_pane = editor.pane_id;
|
||||||
|
historical_diff(&mut editor, &root, vec!["old".into()], vec!["new".into()]);
|
||||||
|
editor.execute("comment");
|
||||||
|
type_text(&mut editor, "iUnsaved feedback");
|
||||||
|
editor.handle(Input::Escape);
|
||||||
|
let draft = editor.buffer_id();
|
||||||
|
let panel = editor.review_pane.unwrap();
|
||||||
|
let scope = editor.local_review.document.comparison.clone();
|
||||||
|
editor.execute("review.new");
|
||||||
|
assert!(editor.message().contains("Save the current local review"));
|
||||||
|
assert_eq!(editor.context().text, "Unsaved feedback");
|
||||||
|
editor.select_pane(source);
|
||||||
|
editor.execute("review.overview");
|
||||||
|
assert!(editor.message().contains("changing review scopes"));
|
||||||
|
assert_eq!(editor.local_review.document.comparison, scope);
|
||||||
|
editor.select_pane(comparison_pane);
|
||||||
|
let comparison = editor.workspace_diff().unwrap().comparison_id;
|
||||||
|
editor.act(Action::DiffModeToggle);
|
||||||
|
assert_eq!(editor.workspace_diff().unwrap().comparison_id, comparison);
|
||||||
|
assert!(editor.workspace_diff().unwrap().comparison.is_some());
|
||||||
|
editor.execute("diff");
|
||||||
|
assert_eq!(editor.workspace_diff().unwrap().comparison_id, comparison);
|
||||||
|
editor.select_pane(panel);
|
||||||
|
assert_eq!(editor.buffer_id(), draft);
|
||||||
|
assert_eq!(editor.context().text, "Unsaved feedback");
|
||||||
|
editor.execute("w");
|
||||||
|
editor.handle(Input::Escape);
|
||||||
|
editor.select_pane(source);
|
||||||
|
editor.execute("review.overview");
|
||||||
|
assert!(editor.local_review.document.comparison.is_none());
|
||||||
|
assert!(editor.local_review.document.comments.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn working_refresh_keeps_review_scope_and_drafts_but_explicit_reopen_uses_new_identities() {
|
||||||
|
let directory = Directory::new();
|
||||||
|
let (mut editor, root, _) = source_review_editor(&directory, "source\n");
|
||||||
|
fs::write(root.join("file.txt"), "original\n").unwrap();
|
||||||
|
working_diff(&mut editor, &root, &["file.txt"]);
|
||||||
|
editor.handle(Input::Enter);
|
||||||
|
editor.execute("comment");
|
||||||
|
type_text(&mut editor, "iWorking feedback");
|
||||||
|
editor.handle(Input::Escape);
|
||||||
|
editor.execute("w");
|
||||||
|
editor.handle(Input::Escape);
|
||||||
|
let original_id = editor.local_review.document.storage_id;
|
||||||
|
let scope = editor.local_review.document.comparison.clone();
|
||||||
|
editor.execute("review.summary");
|
||||||
|
type_text(&mut editor, "iUnsaved working summary");
|
||||||
|
editor.handle(Input::Escape);
|
||||||
|
let panel = editor.review_pane.unwrap();
|
||||||
|
let owner = editor.diff_owner.unwrap();
|
||||||
|
editor.queue_checkout_refreshes(true);
|
||||||
|
let (_, request, _) = editor.take_next_diff_request().unwrap();
|
||||||
|
let mut replacement = editor.with_pane(owner, |editor| {
|
||||||
|
editor.diff.as_mut().unwrap().comparison.take().unwrap()
|
||||||
|
});
|
||||||
|
replacement.revisions[1] = "working:changed".into();
|
||||||
|
replacement.files[0].after = vec!["refreshed".into()];
|
||||||
|
assert!(editor.apply_diff_result(request, Ok(Outcome::CheckoutRefresh(Some(replacement)))));
|
||||||
|
assert_eq!(editor.local_review.document.comparison, scope);
|
||||||
|
assert_eq!(editor.local_review.document.storage_id, original_id);
|
||||||
|
assert_eq!(
|
||||||
|
editor.local_review.document.comments[0].body,
|
||||||
|
"Working feedback"
|
||||||
|
);
|
||||||
|
assert!(editor.current_review_dirty());
|
||||||
|
editor.select_pane(panel);
|
||||||
|
assert_eq!(editor.context().text, "Unsaved working summary");
|
||||||
|
editor.execute("w");
|
||||||
|
editor.handle(Input::Escape);
|
||||||
|
working_diff_with_revisions(
|
||||||
|
&mut editor,
|
||||||
|
&root,
|
||||||
|
&["file.txt"],
|
||||||
|
["base", "working:changed"],
|
||||||
|
);
|
||||||
|
assert!(editor.local_review.document.comments.is_empty());
|
||||||
|
assert!(editor.local_review.document.summary.is_empty());
|
||||||
|
editor.execute("review.verdict looks_good");
|
||||||
|
assert_ne!(editor.local_review.document.storage_id, original_id);
|
||||||
|
editor.handle(Input::Escape);
|
||||||
|
working_diff(&mut editor, &root, &["file.txt"]);
|
||||||
|
assert_eq!(editor.local_review.document.storage_id, original_id);
|
||||||
|
assert_eq!(
|
||||||
|
editor.local_review.document.summary,
|
||||||
|
"Unsaved working summary"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn moved_bookmark_selects_new_review_and_dirty_draft_blocks_endpoint_replacement() {
|
||||||
|
use crate::diff::Comparison;
|
||||||
|
let directory = Directory::new();
|
||||||
|
let (mut editor, root, _) = source_review_editor(&directory, "source\n");
|
||||||
|
historical_diff(&mut editor, &root, vec!["old".into()], vec!["new".into()]);
|
||||||
|
editor.execute("review.verdict looks_good");
|
||||||
|
let original_id = editor.local_review.document.storage_id;
|
||||||
|
editor.handle(Input::Escape);
|
||||||
|
editor.execute("review.summary");
|
||||||
|
type_text(&mut editor, "iBookmark draft");
|
||||||
|
editor.handle(Input::Escape);
|
||||||
|
let panel = editor.review_pane.unwrap();
|
||||||
|
let moved = || Comparison {
|
||||||
|
revisions: ["base".into(), "moved-head".into()],
|
||||||
|
root: root.clone(),
|
||||||
|
before: "base".into(),
|
||||||
|
after: "head".into(),
|
||||||
|
blame: Default::default(),
|
||||||
|
editable_paths: Default::default(),
|
||||||
|
files: Vec::new(),
|
||||||
|
};
|
||||||
|
editor.queue_checkout_refreshes(false);
|
||||||
|
let (_, request, _) = editor.take_next_diff_request().unwrap();
|
||||||
|
assert!(editor.apply_diff_result(request, Ok(Outcome::BookmarkRefresh(moved()))));
|
||||||
|
assert_eq!(editor.local_review.document.storage_id, original_id);
|
||||||
|
assert_eq!(
|
||||||
|
editor
|
||||||
|
.workspace_diff()
|
||||||
|
.unwrap()
|
||||||
|
.comparison
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.revisions[1],
|
||||||
|
"head"
|
||||||
|
);
|
||||||
|
editor.select_pane(panel);
|
||||||
|
assert_eq!(editor.context().text, "Bookmark draft");
|
||||||
|
editor.execute("w");
|
||||||
|
editor.handle(Input::Escape);
|
||||||
|
editor.queue_checkout_refreshes(false);
|
||||||
|
let (_, request, _) = editor.take_next_diff_request().unwrap();
|
||||||
|
assert!(editor.apply_diff_result(request, Ok(Outcome::BookmarkRefresh(moved()))));
|
||||||
|
assert_eq!(
|
||||||
|
editor.local_review.document.comparison,
|
||||||
|
Some(["base".into(), "moved-head".into()])
|
||||||
|
);
|
||||||
|
assert!(editor.local_review.document.verdict.is_none());
|
||||||
|
assert!(editor.local_review.document.summary.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn source_review_drafts_guard_source_pane_close_and_saved_panels_retarget() {
|
fn source_review_drafts_guard_source_pane_close_and_saved_panels_retarget() {
|
||||||
let directory = Directory::new();
|
let directory = Directory::new();
|
||||||
@@ -3446,6 +3765,8 @@ fn review_navigation_maps_working_ranges_and_preserves_stale_snapshots() {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
.revisions[1] = "working:old".into();
|
.revisions[1] = "working:old".into();
|
||||||
editor.local_review.document = crate::review::Review::new(root);
|
editor.local_review.document = crate::review::Review::new(root);
|
||||||
|
editor.local_review.document.comparison =
|
||||||
|
editor.workspace_diff().unwrap().review_comparison.clone();
|
||||||
let anchor = Anchor {
|
let anchor = Anchor {
|
||||||
revision: "working:old".into(),
|
revision: "working:old".into(),
|
||||||
path: "file.txt".into(),
|
path: "file.txt".into(),
|
||||||
@@ -7598,6 +7919,7 @@ mod system_clipboard {
|
|||||||
let root = fixture.0.0.canonicalize().unwrap();
|
let root = fixture.0.0.canonicalize().unwrap();
|
||||||
let storage = root.join("reviews");
|
let storage = root.join("reviews");
|
||||||
let mut review = Review::new(root.clone());
|
let mut review = Review::new(root.clone());
|
||||||
|
review.comparison = Some(["base".into(), "head".into()]);
|
||||||
review.summary = "Review summary".into();
|
review.summary = "Review summary".into();
|
||||||
review.verdict = Some(Verdict::ChangesRequested);
|
review.verdict = Some(Verdict::ChangesRequested);
|
||||||
review.comments = vec![
|
review.comments = vec![
|
||||||
|
|||||||
@@ -463,6 +463,80 @@ fn working_jj_conflicted_parent_uses_cli_projection_without_snapshotting() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn jj_catalog_exposes_fixed_metadata_and_merge_parents_without_snapshotting() {
|
||||||
|
let temp = Temporary::new().unwrap();
|
||||||
|
let root = &temp.0;
|
||||||
|
fixture_command(root, "jj", &["git", "init", "--no-colocate", "."]);
|
||||||
|
fixture_command(root, "jj", &["describe", "-m", "Base"]);
|
||||||
|
let id = |root: &Path| {
|
||||||
|
fixture_command(
|
||||||
|
root,
|
||||||
|
"jj",
|
||||||
|
&[
|
||||||
|
"--ignore-working-copy",
|
||||||
|
"log",
|
||||||
|
"--no-graph",
|
||||||
|
"-r",
|
||||||
|
"@",
|
||||||
|
"-T",
|
||||||
|
"commit_id",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let base = id(root);
|
||||||
|
fixture_command(root, "jj", &["bookmark", "create", "base"]);
|
||||||
|
fixture_command(root, "jj", &["new", &base, "-m", "Left"]);
|
||||||
|
let left = id(root);
|
||||||
|
fixture_command(root, "jj", &["new", &base, "-m", "Right"]);
|
||||||
|
let right = id(root);
|
||||||
|
fixture_command(root, "jj", &["new", &left, &right, "-m", "Merge"]);
|
||||||
|
let current = id(root);
|
||||||
|
let operation = fixture_command(
|
||||||
|
root,
|
||||||
|
"jj",
|
||||||
|
&[
|
||||||
|
"--ignore-working-copy",
|
||||||
|
"op",
|
||||||
|
"log",
|
||||||
|
"--no-graph",
|
||||||
|
"-n",
|
||||||
|
"1",
|
||||||
|
"-T",
|
||||||
|
"id",
|
||||||
|
],
|
||||||
|
);
|
||||||
|
let mut repository = Repository::open(root, Arc::new(AtomicBool::new(false))).unwrap();
|
||||||
|
let catalog = repository.catalog().unwrap();
|
||||||
|
let merge = &catalog.graph[¤t];
|
||||||
|
assert_eq!(merge.parents, [left, right]);
|
||||||
|
assert_eq!(merge.author, "Picker Author");
|
||||||
|
assert_eq!(merge.timestamp, "2001-01-01 00:00");
|
||||||
|
assert_eq!(merge.description, "Merge");
|
||||||
|
assert!(!merge.change_id.is_empty());
|
||||||
|
assert!(merge.current);
|
||||||
|
assert_eq!(catalog.graph[&base].bookmarks, "base");
|
||||||
|
assert!(!catalog.graph[&base].current);
|
||||||
|
assert!(catalog.graph[&"0".repeat(40)].immutable);
|
||||||
|
assert_eq!(
|
||||||
|
fixture_command(
|
||||||
|
root,
|
||||||
|
"jj",
|
||||||
|
&[
|
||||||
|
"--ignore-working-copy",
|
||||||
|
"op",
|
||||||
|
"log",
|
||||||
|
"--no-graph",
|
||||||
|
"-n",
|
||||||
|
"1",
|
||||||
|
"-T",
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
),
|
||||||
|
operation
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn cancellation_kills_pipe_holding_children_and_drains_both_streams() {
|
fn cancellation_kills_pipe_holding_children_and_drains_both_streams() {
|
||||||
let temp = Temporary::new().unwrap();
|
let temp = Temporary::new().unwrap();
|
||||||
|
|||||||
+212
-5
@@ -1,5 +1,72 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn text_excerpts_use_detected_languages_and_cannot_close_their_own_fences() {
|
||||||
|
use crate::plugin::TextPosition;
|
||||||
|
|
||||||
|
for (path, content, start_row, expected) in [
|
||||||
|
(
|
||||||
|
"source.hon",
|
||||||
|
"[8]Builtin",
|
||||||
|
0,
|
||||||
|
"```hon\n[8]Builtin\n```\nFeedback **outside**\n",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"source.rs",
|
||||||
|
"let value = \"界\";\n",
|
||||||
|
0,
|
||||||
|
"```rust\nlet value = \"界\";\n```\nFeedback **outside**\n",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Makefile",
|
||||||
|
"all:\n\techo ok",
|
||||||
|
0,
|
||||||
|
"```makefile\nall:\n\techo ok\n```\nFeedback **outside**\n",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"script",
|
||||||
|
"#!/usr/bin/env python3\nprint('界')\n",
|
||||||
|
1,
|
||||||
|
"```python\nprint('界')\n```\nFeedback **outside**\n",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"unknown.data",
|
||||||
|
"e\u{301}👩💻\n界",
|
||||||
|
0,
|
||||||
|
"```\ne\u{301}👩💻\n界\n```\nFeedback **outside**\n",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"example.md",
|
||||||
|
"```rust\n````\n```",
|
||||||
|
0,
|
||||||
|
"`````markdown\n```rust\n````\n```\n`````\nFeedback **outside**\n",
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let mut review = Review::default();
|
||||||
|
review.comments.push(Comment {
|
||||||
|
id: 1,
|
||||||
|
resolved: false,
|
||||||
|
body: "Feedback **outside**".into(),
|
||||||
|
anchor: Anchor {
|
||||||
|
revision: "snapshot".into(),
|
||||||
|
path: path.into(),
|
||||||
|
side: Side::After,
|
||||||
|
start: TextPosition {
|
||||||
|
row: start_row,
|
||||||
|
column: 0,
|
||||||
|
},
|
||||||
|
end: TextPosition {
|
||||||
|
row: content.split('\n').count() - 1,
|
||||||
|
column: content.rsplit('\n').next().unwrap().graphemes(true).count(),
|
||||||
|
},
|
||||||
|
content: content.into(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
let text = review.format_text(false);
|
||||||
|
assert!(text.ends_with(expected), "{path}: {text}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct Directory(PathBuf);
|
struct Directory(PathBuf);
|
||||||
impl Drop for Directory {
|
impl Drop for Directory {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
@@ -84,7 +151,7 @@ fn database_restarts_preserve_identity_and_exact_stale_anchors() {
|
|||||||
let exported = fs::read(&path).unwrap();
|
let exported = fs::read(&path).unwrap();
|
||||||
let json: serde_json::Value = serde_json::from_slice(&exported).unwrap();
|
let json: serde_json::Value = serde_json::from_slice(&exported).unwrap();
|
||||||
assert_eq!(json["comments"][0]["resolved"], true);
|
assert_eq!(json["comments"][0]["resolved"], true);
|
||||||
assert_eq!(json["version"], 4);
|
assert_eq!(json["version"], 5);
|
||||||
assert_eq!(json["reviewed_files"][0]["after"], "live 👩🔬\r\n");
|
assert_eq!(json["reviewed_files"][0]["after"], "live 👩🔬\r\n");
|
||||||
assert!(json["reviewed_files"][1]["after"].is_null());
|
assert!(json["reviewed_files"][1]["after"].is_null());
|
||||||
assert_eq!(json["reviewed_files"][2]["after"], "");
|
assert_eq!(json["reviewed_files"][2]["after"], "");
|
||||||
@@ -122,6 +189,139 @@ fn database_restarts_preserve_identity_and_exact_stale_anchors() {
|
|||||||
assert_eq!(loaded.verdict, verdict);
|
assert_eq!(loaded.verdict, verdict);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn comparison_scopes_roundtrip_in_order_and_cannot_move_saved_reviews() {
|
||||||
|
let directory = directory();
|
||||||
|
let root = directory.0.canonicalize().unwrap();
|
||||||
|
let storage = root.join("storage");
|
||||||
|
let mut store = Store::open(&storage).unwrap();
|
||||||
|
let mut source = Review::new(root.clone());
|
||||||
|
store.save(&mut source).unwrap();
|
||||||
|
let endpoints = ["frozen-base".to_owned(), "working:frozen-tip".to_owned()];
|
||||||
|
let mut comparison = Review::new(root.clone());
|
||||||
|
comparison.comparison = Some(endpoints.clone());
|
||||||
|
store.save(&mut comparison).unwrap();
|
||||||
|
let id = comparison.storage_id.unwrap();
|
||||||
|
assert!(source.summary_text().contains("Scope: source"));
|
||||||
|
assert!(
|
||||||
|
comparison
|
||||||
|
.summary_text()
|
||||||
|
.contains("Scope: comparison frozen-base → working:frozen-tip")
|
||||||
|
);
|
||||||
|
let mut reversed = Review::new(root.clone());
|
||||||
|
reversed.comparison = Some([endpoints[1].clone(), endpoints[0].clone()]);
|
||||||
|
store.save(&mut reversed).unwrap();
|
||||||
|
drop(store);
|
||||||
|
let mut store = Store::open(&storage).unwrap();
|
||||||
|
let reviews = store.list(&root).unwrap();
|
||||||
|
assert_eq!(reviews.len(), 3);
|
||||||
|
assert_eq!(reviews[0].comparison, reversed.comparison);
|
||||||
|
assert_eq!(reviews[1].comparison, Some(endpoints.clone()));
|
||||||
|
assert_eq!(reviews[2].comparison, None);
|
||||||
|
let mut loaded = store.load(&root, id).unwrap();
|
||||||
|
let path = root.join("comparison.json");
|
||||||
|
loaded.save(&path).unwrap();
|
||||||
|
let json: serde_json::Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
|
||||||
|
assert_eq!(json["comparison"], serde_json::json!(endpoints));
|
||||||
|
let imported = Review::load(&path, &root).unwrap();
|
||||||
|
assert_eq!(imported.comparison, loaded.comparison);
|
||||||
|
loaded.comparison = None;
|
||||||
|
assert!(store.save(&mut loaded).unwrap_err().contains("scope"));
|
||||||
|
assert_eq!(store.load(&root, id).unwrap().comparison, Some(endpoints));
|
||||||
|
assert_eq!(loaded.storage_revision, 1);
|
||||||
|
source.comparison = reversed.comparison;
|
||||||
|
assert!(store.save(&mut source).is_err());
|
||||||
|
assert_eq!(
|
||||||
|
store
|
||||||
|
.load(&root, source.storage_id.unwrap())
|
||||||
|
.unwrap()
|
||||||
|
.comparison,
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_comparison_scopes_and_old_json_are_rejected_without_replacing_data() {
|
||||||
|
let directory = directory();
|
||||||
|
let root = directory.0.canonicalize().unwrap();
|
||||||
|
let mut review = Review::new(root.clone());
|
||||||
|
let path = root.join("review.json");
|
||||||
|
review.save(&path).unwrap();
|
||||||
|
let original = fs::read(&path).unwrap();
|
||||||
|
let mut store = Store::open(&root.join("storage")).unwrap();
|
||||||
|
for endpoint in ["", "bad\nrevision", "bad\0revision"] {
|
||||||
|
for side in [0, 1] {
|
||||||
|
let mut endpoints = ["base".to_owned(), "tip".to_owned()];
|
||||||
|
endpoints[side] = endpoint.into();
|
||||||
|
review.comparison = Some(endpoints);
|
||||||
|
assert!(review.save(&path).unwrap_err().contains("immutable"));
|
||||||
|
assert!(store.save(&mut review).unwrap_err().contains("immutable"));
|
||||||
|
assert_eq!(fs::read(&path).unwrap(), original);
|
||||||
|
assert_eq!((review.storage_id, review.storage_revision), (None, 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(store.list(&root).unwrap().is_empty());
|
||||||
|
let mut old: serde_json::Value = serde_json::from_slice(&original).unwrap();
|
||||||
|
old["version"] = 4.into();
|
||||||
|
old.as_object_mut().unwrap().remove("comparison");
|
||||||
|
let bytes = serde_json::to_vec(&old).unwrap();
|
||||||
|
fs::write(&path, &bytes).unwrap();
|
||||||
|
assert!(
|
||||||
|
Review::load(&path, &root)
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("Unsupported review version 4")
|
||||||
|
);
|
||||||
|
assert_eq!(fs::read(&path).unwrap(), bytes);
|
||||||
|
let fresh = Review::new(root.clone());
|
||||||
|
assert!(fresh.save(&path).is_err());
|
||||||
|
assert_eq!(fs::read(&path).unwrap(), bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn comparison_rows_enforce_complete_pairs_and_the_review_size_limit() {
|
||||||
|
let directory = directory();
|
||||||
|
let root = directory.0.canonicalize().unwrap();
|
||||||
|
let mut store = Store::open(&root.join("storage")).unwrap();
|
||||||
|
let mut review = Review::new(root.clone());
|
||||||
|
store.save(&mut review).unwrap();
|
||||||
|
let id = review.storage_id.unwrap();
|
||||||
|
for update in [
|
||||||
|
"before_revision = 'base'",
|
||||||
|
"after_revision = 'tip'",
|
||||||
|
"before_revision = '', after_revision = 'tip'",
|
||||||
|
"before_revision = 'base', after_revision = ''",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
store
|
||||||
|
.connection
|
||||||
|
.execute(&format!("UPDATE reviews SET {update} WHERE id = ?1"), [id],)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
store
|
||||||
|
.connection
|
||||||
|
.execute(
|
||||||
|
"UPDATE reviews SET before_revision = 'base', after_revision = char(10) WHERE id = ?1",
|
||||||
|
[id],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(store.load(&root, id).unwrap_err().contains("immutable"));
|
||||||
|
store
|
||||||
|
.connection
|
||||||
|
.execute(
|
||||||
|
"UPDATE reviews SET after_revision = hex(zeroblob(?1)) WHERE id = ?2",
|
||||||
|
params![(LIMIT / 2) as i64, id],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(store.load(&root, id).unwrap_err().contains("64 MiB"));
|
||||||
|
assert!(store.list(&root).unwrap_err().contains("64 MiB"));
|
||||||
|
review.comparison = Some(["base".into(), "x".repeat(LIMIT)]);
|
||||||
|
assert!(store.save(&mut review).unwrap_err().contains("64 MiB"));
|
||||||
|
let path = root.join("too-large.json");
|
||||||
|
assert!(review.save(&path).unwrap_err().contains("64 MiB"));
|
||||||
|
assert!(!path.exists());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn database_repository_identity_is_canonical_and_independent() {
|
fn database_repository_identity_is_canonical_and_independent() {
|
||||||
let first = directory();
|
let first = directory();
|
||||||
@@ -201,9 +401,16 @@ fn database_conflicts_and_locked_writes_keep_drafts_and_identity() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn incompatible_database_schemas_are_preserved() {
|
fn incompatible_database_schemas_are_preserved() {
|
||||||
let previous = format!(
|
let previous = format!(
|
||||||
"{REVIEWS_SCHEMA}; {COMMENTS_SCHEMA};
|
"CREATE TABLE reviews (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id > 0),
|
||||||
|
root TEXT NOT NULL,
|
||||||
|
revision INTEGER NOT NULL CHECK (revision > 0),
|
||||||
|
summary TEXT NOT NULL,
|
||||||
|
verdict TEXT CHECK (verdict IN ('looks_good', 'changes_requested'))
|
||||||
|
) STRICT;
|
||||||
|
{COMMENTS_SCHEMA}; {REVIEWED_FILES_SCHEMA};
|
||||||
INSERT INTO reviews VALUES (1, '/old/repo', 1, 'Preserve me', 'looks_good');
|
INSERT INTO reviews VALUES (1, '/old/repo', 1, 'Preserve me', 'looks_good');
|
||||||
PRAGMA user_version = 2"
|
PRAGMA user_version = 3"
|
||||||
);
|
);
|
||||||
for schema in [
|
for schema in [
|
||||||
previous.as_str(),
|
previous.as_str(),
|
||||||
@@ -732,12 +939,12 @@ fn exact_grapheme_ranges_roundtrip_and_reject_invalid_coordinates() {
|
|||||||
.as_object_mut()
|
.as_object_mut()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.remove("resolved");
|
.remove("resolved");
|
||||||
for version in [2, 3, 4] {
|
for version in [2, 3, 4, 5] {
|
||||||
missing_resolution["version"] = version.into();
|
missing_resolution["version"] = version.into();
|
||||||
let bytes = serde_json::to_vec(&missing_resolution).unwrap();
|
let bytes = serde_json::to_vec(&missing_resolution).unwrap();
|
||||||
fs::write(&path, &bytes).unwrap();
|
fs::write(&path, &bytes).unwrap();
|
||||||
let error = Review::load(&path, &root).unwrap_err();
|
let error = Review::load(&path, &root).unwrap_err();
|
||||||
if version < 4 {
|
if version < 5 {
|
||||||
assert!(error.contains("Unsupported review version"));
|
assert!(error.contains("Unsupported review version"));
|
||||||
} else {
|
} else {
|
||||||
assert!(error.contains("missing field"));
|
assert!(error.contains("missing field"));
|
||||||
|
|||||||
@@ -653,6 +653,69 @@ fn git_gutter_config_modes_default_and_rejection() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cursor_config_partial_shapes_and_invalid_options() {
|
||||||
|
use crate::cursor::CursorShape::{Bar, Block, Underline};
|
||||||
|
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
for (options, mode_colors, shapes) in [
|
||||||
|
(
|
||||||
|
r#"{ insert: :bar, "visual" => "underline" }"#,
|
||||||
|
true,
|
||||||
|
[Block, Bar, Block, Block, Underline],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
r#"{ mode_colors: false, normal: "bar", insert: :underline, command: :bar, search: "underline", visual: :block }"#,
|
||||||
|
false,
|
||||||
|
[Bar, Underline, Bar, Underline, Block],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
r#"{ "mode_colors" => true, normal: :underline, insert: "block", command: "underline", search: :bar, visual: "bar" }"#,
|
||||||
|
true,
|
||||||
|
[Underline, Block, Underline, Bar, Bar],
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let path = fixture.file(
|
||||||
|
"config.rb",
|
||||||
|
&format!("Vamp.configure {{ |config| config.cursor = {options} }}"),
|
||||||
|
);
|
||||||
|
let cursor = load_config(&path).unwrap().cursor;
|
||||||
|
assert_eq!(cursor.mode_colors, mode_colors);
|
||||||
|
assert_eq!(
|
||||||
|
[
|
||||||
|
cursor.normal,
|
||||||
|
cursor.insert,
|
||||||
|
cursor.command,
|
||||||
|
cursor.search,
|
||||||
|
cursor.visual
|
||||||
|
],
|
||||||
|
shapes
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for options in [
|
||||||
|
"nil",
|
||||||
|
"false",
|
||||||
|
"[]",
|
||||||
|
"{ mode_colors: nil }",
|
||||||
|
"{ mode_colors: 1 }",
|
||||||
|
r#"{ mode_colors: "true" }"#,
|
||||||
|
"{ insert: nil }",
|
||||||
|
"{ insert: false }",
|
||||||
|
"{ insert: 1 }",
|
||||||
|
"{ insert: :beam }",
|
||||||
|
r#"{ normal: "" }"#,
|
||||||
|
"{ unknown: :block }",
|
||||||
|
"{ 1 => :block }",
|
||||||
|
r#"{ insert: :bar, "insert" => :block }"#,
|
||||||
|
] {
|
||||||
|
let path = fixture.file(
|
||||||
|
"config.rb",
|
||||||
|
&format!("Vamp.configure {{ |config| config.cursor = {options} }}"),
|
||||||
|
);
|
||||||
|
assert!(load_config(&path).is_err(), "{options}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn git_blame_boolean_config_typed_toggle_palette_and_wire_validation() {
|
fn git_blame_boolean_config_typed_toggle_palette_and_wire_validation() {
|
||||||
let fixture = Fixture::new();
|
let fixture = Fixture::new();
|
||||||
|
|||||||
@@ -556,6 +556,7 @@ fn checkout_diff_polling_uses_injected_interval_independently_of_endpoint_follow
|
|||||||
id,
|
id,
|
||||||
Ok(DiffOutcome::Catalog(Catalog {
|
Ok(DiffOutcome::Catalog(Catalog {
|
||||||
kind: RepoKind::Git,
|
kind: RepoKind::Git,
|
||||||
|
graph: Default::default(),
|
||||||
notice: None,
|
notice: None,
|
||||||
entries: [
|
entries: [
|
||||||
RevisionTarget::Working,
|
RevisionTarget::Working,
|
||||||
@@ -3725,6 +3726,7 @@ fn async_responses_edit_only_current_bound_working_after_and_refresh_projection(
|
|||||||
id,
|
id,
|
||||||
Ok(DiffOutcome::Catalog(Catalog {
|
Ok(DiffOutcome::Catalog(Catalog {
|
||||||
kind: RepoKind::Git,
|
kind: RepoKind::Git,
|
||||||
|
graph: Default::default(),
|
||||||
notice: None,
|
notice: None,
|
||||||
entries: vec![
|
entries: vec![
|
||||||
revision(RevisionTarget::Commit("base".into()), "base"),
|
revision(RevisionTarget::Commit("base".into()), "base"),
|
||||||
|
|||||||
+536
-233
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user