fix: retain selections and float local review editors

This commit is contained in:
Valdemar Lorenzen
2026-10-16 14:16:29 +02:00
parent d67b874b87
commit 4cc0453344
9 changed files with 336 additions and 68 deletions
+6 -1
View File
@@ -1,11 +1,16 @@
- [x] we should be able to execute commands in visual mode, since some commands may relevant to selections.
- [x] diff view: local review UX first, independently of GitHub integration, for reviewing local changes and delivering feedback to coding agents.
- add comments at the caret or on selected line ranges on either diff side; compose/edit with normal editor interaction, delete drafts, navigate comments, and open a review overview.
- start with gutter markers and a focused comment panel rather than inserting comment text into source rows; iterate on the interaction before adding remote operations.
- use gutter markers and a focused floating comment editor over the whole diff text workspace, without adding source-side splits or inserting comment text into source rows.
- support a review summary and local verdict: Looks good / Changes requested.
- save and reopen reviews using local files that coding agents can read; no server or client/server protocol.
- anchor comments to immutable revision IDs, paths, sides, and source line ranges, never screen coordinates or mutable file indices.
- preserve original anchors and mark comments stale when the reviewed snapshot no longer matches; defer automatic reanchoring.
- [ ] persist local reviews automatically.
- default to $XDG_DATA_HOME/vamp/reviews/, falling back to ~/.local/share/vamp/reviews/; allow a configurable review_directory.
- key files by repository identity and review ID, preserving reviews and stale comments across revision changes.
- autosave committed comment edits, summaries, verdicts, and deletions; retain explicit save-as for sharing with agents.
- surface write failures without discarding drafts.
- [ ] 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.
- preserve explicit PR identity and reviewed commit IDs; validate anchors against GitHub's PR diff and recheck the PR head before submission.
+2 -2
View File
@@ -16,7 +16,7 @@ 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.
- `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.
- `review`: owns versioned local JSON review documents, immutable source anchors, validation, bounded reads, and atomic writes with external-change protection. It has no terminal or network operations. Comparison state owns the active review; `editor_review` keeps native comment/summary buffers and the read-only overview attached to their owning tab, synchronizes drafts on review commands, and guards unsaved teardown. Working snapshot IDs hash the private captured paths, modes, contents, and notices; anchors also retain exact file text to account for live edits without reanchoring.
- `review`: owns versioned local JSON review documents, immutable source anchors, validation, bounded reads, and atomic writes with external-change protection. It has no terminal or network operations. Comparison state owns the active review; `editor_review` keeps native comment/summary buffers and the read-only overview attached to their owning tab, synchronizes drafts on review commands, and guards unsaved teardown. The floating review editor has a tab-owned native pane outside the workspace `PaneTree`; rendering overlays it on the diff owner's rectangle without changing the source layout. Dismissal retains drafts and restores source focus. Working snapshot IDs hash the private captured paths, modes, contents, and notices; anchors also retain exact file text to account for live edits without reanchoring.
- `clipboard`: owns native plain-text provider selection and bounded subprocess I/O; it never computes selections or editing offsets.
- `syntax`: owns Tree-sitter grammar detection, per-buffer incremental trees, bundled highlight/local/injection queries, and syntax byte spans; it has no terminal color/geometry or editing dependency.
- `markdown`: owns native Markdown link/reference resolution, local destination decoding, and heading fragment lookup through the installed block/inline grammars. It has no editor lifecycle, terminal, LSP, or external-process dependency.
@@ -37,7 +37,7 @@ Frontends drive `EditorSession::handle` or `execute`, periodically call `poll()`
Session polling schedules synchronous `Editor::refresh_files()` metadata scans every 250 ms; the editor method itself is unthrottled and reports whether a redraw is needed. Scans include active and parked live buffers across inactive tabs/panes, but never immutable historical snapshots, and read content only after metadata changes. Valid changed disk text replaces only clean buffers, updates the saved baseline and text revision, clears stale undo/redo, preserves/clamps every associated view, clears diagnostics and stale requests, remaps retained semantic colors, and refreshes live comparisons. Dirty buffers keep edits; missing, unreadable, or invalid changed files preserve text and report a warning.
Every save independently re-reads disk content and compares its LF-normalized text with the saved baseline, rather than trusting polling or modification timestamps. Normal writes refuse external changes/deletion; newly opened nonexistent paths can be created normally. Typed `core.force_save` / `core.force_save_and_quit` expose canonical `:force_save [path]` / `:force_save_and_quit [path]` and aliases `:w!` / `:wq!`. Force bypasses only the external-change check, retaining snapshot read-only, target-symlink, and another-buffer path-ownership protections. Both save-and-quit variants quit only after a successful save and only when all buffers are clean.
Frontends inspect `selections()` for document-ordered, end-exclusive Visual ranges of grapheme coordinates (empty outside Visual), `carets()` for all active cursors in document order, `cursor()` for the primary, and `selection_count()` for the total target count. `visual_linewise()` is true only for the whole-row shape in `Mode::Visual`; it changes neither binding modes nor Ruby mode names. Linewise ranges expand to complete rows. A range ending at the next row's column 0 includes the previous row's newline; an empty final-row range contains no text. `linewise_rows()` exposes inclusive selected row ranges only in linewise Visual, including an empty final row without changing `selections()` cardinality. The TUI highlights the copied text on those rows and one cell for each copied newline, never unused viewport space or the gutter, and labels their mode VISUAL LINE. Selection normalization, clipboard shape and contents, edit planning, and history remain inside the editor module.
Frontends inspect `selections()` for document-ordered, end-exclusive Visual ranges of grapheme coordinates, including selections retained during command input, `carets()` for all active cursors in document order, `cursor()` for the primary, and `selection_count()` for the total target count. `visual_linewise()` and `linewise_rows()` likewise retain whole-row shape during a command prompt entered from Visual; binding modes and Ruby mode names still report Command. Linewise ranges expand to complete rows. A range ending at the next row's column 0 includes the previous row's newline; an empty final-row range contains no text. `linewise_rows()` includes an empty final row without changing `selections()` cardinality. The TUI highlights copied text and one cell per copied newline, never unused viewport space or the gutter; the mode label remains COMMAND while typing. Selection normalization, clipboard shape and contents, edit planning, and history remain inside the editor module.
The TUI also observes the editor's logical caret-reveal generation. Core motion/navigation dispatch advances it even when the resulting position is unchanged, so a detached viewport can resume following without duplicating key-binding policy or changing text revisions and LSP freshness.
Frontends inspect `palette()` for the optional read-only query, filtered command entries, selected index, effective bindings, and eligibility. Palette input handling and acceptance stay in the editor; registered command execution stays in the session.
Frontends use cached `selection_navigation_hint()` for command lists and `file_navigation_hint()` for the active file view/context, without reconstructing input policy.
+1 -1
View File
@@ -324,7 +324,7 @@ config.bind "leader r o", config.core.diff.review.overview
config.bind "leader r n", config.core.diff.review.next
```
Comment and summary composition uses an ordinary native split pane. `:w` there writes the whole local JSON review, including hidden draft text; choose its destination with `:diff.review.save path`. These actions do not publish reviews or contact a forge. See [local review behavior](editing.md#repository-diff).
Comment and summary composition uses native editing in a floating window over the diff workspace, without adding workspace splits. Escape in Normal mode or `:pane.close` dismisses it while retaining drafts. `:w` there writes the whole local JSON review, including hidden draft text; choose its destination with `:diff.review.save path`. These actions do not publish reviews or contact a forge. See [local review behavior](editing.md#repository-diff).
`config.core.pane.close` and `config.core.pane.force_close` close a focused **docked** file list instead of its editor pane, without discarding buffers or exiting the comparison. A floating picker does not intercept these actions: normal close still protects underlying dirty buffers, while force-close retains its existing discard semantics. For example, bind `config.bind "ctrl+x", config.core.pane.close`; focused tree/selection overrides and printable filter input retain priority. Palette-originated close actions retain the originating dock target through force confirmation.
+1
View File
@@ -38,6 +38,7 @@ Rust tests live alongside their modules; native LSP integration checks are in [`
- Editor checks cover Unicode editing, multi-target state, undo/redo, saving failures, and modal paste acceptance and rejection atomicity.
- Visual-command checks preserve directed characterwise/linewise ranges and multiple carets through command cancellation and plugin dispatch, exercise remapped command entry, and submit `:diff.comment` on either historical side. Live PTY smoke selects lines, cancels a prompt, then runs `:diff.comment` directly and verifies the saved original range without using the palette.
- Presentation regressions render retained characterwise/linewise highlights during command entry in ordinary, split-diff, and unified views. Floating-review checks preserve source rectangles and the workspace layout, place the caret inside the overlay, retain dismissed drafts across tab switches, and restore source focus on Escape or pane close. Live PTY smoke edits, dismisses, reopens, and saves a floating comment and summary before returning to the unchanged diff.
- Markdown navigation checks cover inline/reference destinations, escaped/percent-encoded paths, heading markup and duplicates, same-document fragments, dirty target reuse, failed navigation atomicity, primary-only following, and `gf` prefix cancellation/configuration overrides.
- Jump-history checks cover cross-buffer/same-document navigation, search and picker recording, counts and repeated visits, bounded history, branch truncation, closed buffers, edited-coordinate clamping, dirty text/undo preservation, independent Ctrl+i/Tab bindings, and custom bindings. Diff checks exercise Ctrl+i on either side in split/unified views, both control-letter and modified-Tab terminal events, and plain Tab pane cycling. Real LSP fixtures cover definition round-trips and delayed replies after leaving and returning to the same source location.
- Tab checks cover shared edits/history with independent pane layouts, focused views, explorers and viewports; cross-tab close guards, immutable snapshots, hidden comparison refresh/result routing, and separate repository workers. Rendered checks cover sanitized/dirty titles, active-tab visibility in narrow terminals, overlay row reservation, and exclusion of hidden diff geometry from palette polling and viewport reporting.
+3 -3
View File
@@ -140,7 +140,7 @@ Visual `r<char>` replaces every selected grapheme with the literal character, pr
Bracketed paste broadcasts the same literal text to all Insert carets in a separate undo group, distinct from typing before/after it. A rejected paste leaves the pending change intact. Normal supports counted `yy`, `dd`, `cc`, `dw`, `db`, `cw`, and `cb`, but not arbitrary operator–motion grammar; blockwise Visual is also unsupported. `c`, `d`, and `y` type literal text in Insert mode.
Clipboard policy is session-wide and defaults to the system clipboard. Copy/cut store fragments in document order: one fragment broadcasts to every caret; matching fragment/caret counts pair in document order; any other count refuses paste without changing editing state. Empty clipboard paste does not edit text; copying/cutting only characterwise empty final-row selections does not overwrite either register. Linewise copy/cut retain whole-row shape, including a newline fragment for an empty final row. Open a new search or enter Ex commands after leaving Visual mode. Plugin commands execute once, not once per target: successful text effects collapse to the primary and apply once; rejected or message-only effects preserve the selection set. There is no selection-aware plugin API yet.
Clipboard policy is session-wide and defaults to the system clipboard. Copy/cut store fragments in document order: one fragment broadcasts to every caret; matching fragment/caret counts pair in document order; any other count refuses paste without changing editing state. Empty clipboard paste does not edit text; copying/cutting only characterwise empty final-row selections does not overwrite either register. Linewise copy/cut retain whole-row shape, including a newline fragment for an empty final row. Open a new search after leaving Visual mode; Ex commands can be entered directly with `:` while retaining the selection. Plugin commands execute once, not once per target: successful text effects collapse to the primary and apply once; rejected or message-only effects preserve the selection set. There is no selection-aware plugin API yet.
## Clipboard policy
@@ -190,7 +190,7 @@ Submitting an empty search repeats the last query. Esc cancels without moving th
`:` opens compact floating command input titled **Command**; `/` opens input titled **Search** for buffer text search. Titles sit at the top-left border, replacing the displayed leading `:` / `/`. Both input fields have one column of padding on each side. Enter submits and Escape cancels using the existing modal rules. Long Unicode input scrolls horizontally without changing buffer text. Both windows default to the workspace's bottom-left edge, directly above the bottom bar when visible; their [anchors can be configured independently](configuration.md#command-and-search-popup-anchors). They temporarily hide operation notifications and never reserve a permanent prompt/footer row. Tiny windows omit borders/titles or padding when needed to keep input usable.
Command input works in both Normal and Visual modes, including read-only diff panes. Enter dispatches with the original Visual ranges, direction, and linewise shape intact; individual commands decide whether to consume or retain them. Escape returns to the original Visual selection without executing. For example, select lines with `V`, then type `:diff.comment` and press Enter—no command palette is needed. `config.core.command` can also be bound in Visual mode.
Command input works in both Normal and Visual modes, including read-only diff panes. The original selection stays highlighted while typing. Enter dispatches with its ranges, direction, and linewise shape intact; individual commands decide whether to consume or retain them. Escape returns to the original Visual selection without executing. For example, select lines with `V`, then type `:diff.comment` and press Enter—no command palette is needed. `config.core.command` can also be bound in Visual mode.
The latest reported operation result appears in a non-focusable popup at the workspace's bottom-right, just above the bottom bar when visible, with one column of inner padding on each side when space permits. It remains until dismissed or replaced, with no timer. In Normal mode, the first Escape dismisses it without collapsing carets; a subsequent Escape retains ordinary Normal behavior. Existing modal windows, focused explorers/diff selectors, and pending prefixes retain Escape priority. New operation messages reopen the popup; routine successful buffer switches do not duplicate the buffer/path information already shown in the bar.
@@ -454,7 +454,7 @@ Review marks belong only to the open comparison and survive filtering, file navi
**Local review comments** are independent of GitHub and the file-reviewed checkmarks above. Focus either diff text pane and run **`:diff.comment`** to comment at the caret. For a range, make one Visual or Visual Line selection, then type **`:diff.comment`** and press Enter. The palette and configured Visual-mode bindings also work. Multiple independent carets/ranges must be reviewed separately.
Comments and summaries open in a focused native split pane, with their own heading, normal modes, selections, undo/redo, and pane-focus bindings. Source rows are never replaced with comment text. Magenta **`○`** gutter markers identify current comment ranges; **`●`** identifies the selected comment. Markers preserve addition/deletion signs, appear only on the first wrapped segment, and also work in unified view.
Comments, summaries, and the review overview open in a focused floating editor over the whole diff text workspace, not a source-side split. The source layout, file explorer, and viewports stay unchanged. Normal editing, selections, and undo/redo work in editable review buffers. Escape in Normal mode or `:pane.close` dismisses the window and returns to the diff; drafts remain available for reopening and saving. Insert/Visual/Command Escape first retains its usual modal behavior. Source rows are never replaced with comment text. Magenta **`○`** gutter markers identify current comment ranges; **`●`** identifies the selected comment. Markers preserve addition/deletion signs, appear only on the first wrapped segment, and also work in unified view.
| Command | Effect |
| --- | --- |
+48 -6
View File
@@ -1773,6 +1773,7 @@ struct Pane {
struct Tab {
pane_id: usize,
pane_layout: PaneTree,
review_pane: Option<usize>,
file_picker: Option<FilePicker>,
diff: Option<DiffState>,
parked_diff: Option<DiffState>,
@@ -2060,6 +2061,7 @@ pub struct Editor {
next_pane_id: usize,
panes: BTreeMap<usize, Pane>,
pane_layout: PaneTree,
review_pane: Option<usize>,
tab_id: usize,
next_tab_id: usize,
tab_order: Vec<usize>,
@@ -2160,6 +2162,7 @@ impl Editor {
},
)]),
pane_layout: PaneTree::new(1),
review_pane: None,
tab_id: 1,
next_tab_id: 2,
tab_order: vec![1],
@@ -3485,6 +3488,7 @@ impl Editor {
let previous = Tab {
pane_id: std::mem::replace(&mut self.pane_id, next.pane_id),
pane_layout: std::mem::replace(&mut self.pane_layout, next.pane_layout),
review_pane: std::mem::replace(&mut self.review_pane, next.review_pane),
file_picker: std::mem::replace(&mut self.file_picker, next.file_picker),
diff: std::mem::replace(&mut self.diff, next.diff),
parked_diff: std::mem::replace(&mut self.parked_diff, next.parked_diff),
@@ -3533,6 +3537,7 @@ impl Editor {
Tab {
pane_id,
pane_layout: PaneTree::new(pane_id),
review_pane: None,
file_picker: None,
diff: None,
parked_diff: None,
@@ -3635,6 +3640,11 @@ impl Editor {
// No input dispatch or document copies: source documents stay unique in active/parked.
fn select_pane(&mut self, id: usize) {
let id = if self.review_pane == Some(id) && self.workspace_diff().is_none() {
self.panes[&id].origin.unwrap_or(id)
} else {
id
};
if id == self.pane_id {
return;
}
@@ -3660,13 +3670,13 @@ impl Editor {
assert!(self.panes.contains_key(&id), "rendered pane exists");
let focused = self.pane_id;
let tab = self.tab_id;
let target_tab = if self.pane_layout.contains(id) {
let target_tab = if self.pane_layout.contains(id) || self.review_pane == Some(id) {
tab
} else {
*self
.tabs
.iter()
.find(|(_, tab)| tab.pane_layout.contains(id))
.find(|(_, tab)| tab.pane_layout.contains(id) || tab.review_pane == Some(id))
.map(|(id, _)| id)
.expect("pane belongs to a tab")
};
@@ -3943,7 +3953,7 @@ impl Editor {
.panes
.keys()
.copied()
.filter(|id| self.pane_layout.contains(*id))
.filter(|id| self.pane_layout.contains(*id) || self.review_pane == Some(*id))
.collect();
let mut buffers = self.closing_buffers(&panes);
if let Some(resolver) = self.resolver() {
@@ -3992,6 +4002,9 @@ impl Editor {
}
fn close_pane(&mut self, force: bool) {
if self.dismiss_review_panel() {
return;
}
if self.resolver().is_some() {
self.close_tab(force);
return;
@@ -6685,7 +6698,9 @@ impl Editor {
}
/// Whole-row shape belongs to the active buffer, not the Visual binding context.
pub fn visual_linewise(&self) -> bool {
self.active.view.mode == Mode::Visual && self.active.view.linewise
(self.active.view.mode == Mode::Visual
|| (self.active.view.mode == Mode::Command && self.active.view.command_from_visual))
&& self.active.view.linewise
}
/// Inclusive selected rows, including an empty final row with an empty text range.
@@ -6700,9 +6715,11 @@ impl Editor {
})
}
/// Ordered, end-exclusive Visual ranges in document order; empty in other modes.
/// Ordered Visual ranges, including selections retained by command input.
pub fn selections(&self) -> Vec<Selection> {
if self.active.view.mode == Mode::Visual {
if self.active.view.mode == Mode::Visual
|| (self.active.view.mode == Mode::Command && self.active.view.command_from_visual)
{
self.active
.view
.targets
@@ -9351,6 +9368,31 @@ impl Editor {
}
return None;
}
if self.review_panel().is_some()
&& matches!(
entry.action,
Action::PaneSplitHorizontal
| Action::PaneSplitVertical
| Action::PaneFocusLeft
| Action::PaneFocusRight
| Action::PaneFocusUp
| Action::PaneFocusDown
| Action::TabNew
| Action::Edit
| Action::Buffers
| Action::Buffer
| Action::BufferNext
| Action::BufferPrevious
| Action::BufferDelete
| Action::BufferForceDelete
| Action::Diff
| Action::DiffClose
| Action::PullRequests
| Action::Resolve
)
{
self.dismiss_review_panel();
}
if matches!(
entry.action,
Action::Resolve
+44 -15
View File
@@ -22,6 +22,24 @@ impl Editor {
.map(|buffer| buffer.title.as_str())
}
pub fn review_panel(&self) -> Option<usize> {
self.review_pane
.filter(|pane| *pane == self.pane_id && self.workspace_diff().is_some())
}
pub(super) fn dismiss_review_panel(&mut self) -> bool {
let Some(panel) = self.review_panel() else {
return false;
};
let source = self.panes[&panel]
.origin
.expect("review panel has a source pane");
self.finish_pane_interaction();
self.select_pane(source);
self.bind_diff_buffer();
true
}
fn review_diff_mut(&mut self) -> Option<&mut DiffState> {
self.diff.as_mut().or(self.parked_diff.as_mut())
}
@@ -546,22 +564,24 @@ impl Editor {
.is_some_and(|binding| binding.owner == self.tab_id && binding.kind == kind)
})
.map(|buffer| buffer.id);
let panel = self.panes.iter().find_map(|(id, pane)| {
(self.pane_layout.contains(*id)
&& self.buffer(pane.buffer_id).is_some_and(|buffer| {
buffer
.review_buffer
.as_ref()
.is_some_and(|binding| binding.owner == self.tab_id)
}))
.then_some(*id)
});
if let Some(panel) = panel {
self.select_pane(panel);
let panel = if let Some(panel) = self.review_pane {
panel
} else {
self.select_pane(self.diff_owner.unwrap());
self.split_pane(SplitAxis::Vertical);
}
let id = self.next_pane_id;
self.next_pane_id += 1;
self.panes.insert(
id,
Pane {
buffer_id: self.active.id,
origin: self.diff_owner,
views: BTreeMap::from([(self.active.id, self.active.view.inherit())]),
},
);
self.review_pane = Some(id);
id
};
self.panes.get_mut(&panel).unwrap().origin = self.diff_owner;
self.select_pane(panel);
if let Some(id) = existing {
self.switch_buffer(id);
if kind == ReviewText::Overview {
@@ -586,6 +606,15 @@ impl Editor {
}
pub(super) fn handle_review_overview(&mut self, input: &Input) -> bool {
if *input == Input::Escape
&& self.active.view.mode == Mode::Normal
&& self.palette.is_none()
&& self.sequence_pending().is_none()
&& self.count.is_none()
&& self.dismiss_review_panel()
{
return true;
}
if *input == Input::Enter
&& self.active.view.mode == Mode::Normal
&& self.palette.is_none()
+2 -1
View File
@@ -86,7 +86,8 @@ Diff: :diff (Normal); choose Before then After; Working checkout After edits liv
Local reviews: :diff.comment at caret or on a Visual/Visual Line selection
:diff.review.edit/delete [ID], :diff.review.next/previous, :diff.review.overview
:diff.review.summary; :diff.review.verdict looks_good|changes_requested|none
:diff.review.save PATH / :diff.review.open PATH; :w in review panes saves local JSON
:diff.review.save PATH / :diff.review.open PATH; :w in floating review editors saves local JSON
Normal Esc or :pane.close dismisses the floating editor and retains drafts
Snapshot anchors stay immutable; stale comments lose gutter markers; no publishing
Pull requests: :pull_requests [REMOTE]; GitHub/GitLab/Gitea Open/Closed/All tabs, type to search
Enter opens the immutable PR head against its merge base; API auth uses provider tokens or gh/glab
+229 -39
View File
@@ -678,18 +678,23 @@ impl BufferViewports {
.resolver()
.is_some_and(|resolver| resolver.view == ResolverView::History);
let multiple = !resolver_resolution && editor.pane_layout().len() > 1;
let (workspace, dock) = match editor.file_picker() {
Some(picker) if picker.presentation == FilePresentation::Docked => dock_layout(
full_workspace,
if editor.picker_diff().is_some() {
DockSide::Right
} else {
editor.file_explorer_side()
},
picker.focused,
),
_ => (full_workspace, None),
};
let layout_pane = editor
.review_panel()
.and_then(|pane| editor.pane_origin(pane))
.unwrap_or(focused);
let (workspace, dock) =
editor.with_render_pane(layout_pane, |view| match view.file_picker() {
Some(picker) if picker.presentation == FilePresentation::Docked => dock_layout(
full_workspace,
if view.picker_diff().is_some() {
DockSide::Right
} else {
view.file_explorer_side()
},
picker.focused,
),
_ => (full_workspace, None),
});
editor.set_pane_bounds(pane_rect(workspace));
self.rects.clear();
if resolver_history {
@@ -737,9 +742,6 @@ impl BufferViewports {
.retain(|id, _| view.has_buffer(*id));
let (top, left) = self.offsets.entry(view.buffer_id()).or_default();
let syntax = self.syntax.entry(view.buffer_id()).or_default();
let leaf = view.review_panel_title().map_or(leaf, |title| {
resolver_document_panel(frame, leaf, title, id == focused)
});
draw_document(
frame,
view,
@@ -767,22 +769,25 @@ impl BufferViewports {
self.panels.begin_frame();
self.document.geometry = None;
}
if let Some(panel) = dock
&& let Some(picker) = editor.file_picker()
{
self.panels.picker_scroll.distance = editor.scroll_distance();
draw_file_list(
frame,
picker,
panel,
editor.file_navigation_hint(),
&mut self.panels,
editor.picker_diff().and_then(|diff| {
diff.comparison
.as_ref()
.map(|comparison| (comparison.files.as_slice(), &diff.reviewed_files))
}),
);
if let Some(panel) = dock {
self.activate(layout_pane, editor);
editor.with_render_pane(layout_pane, |view| {
if let Some(picker) = view.file_picker() {
self.panels.picker_scroll.distance = view.scroll_distance();
draw_file_list(
frame,
picker,
panel,
view.file_navigation_hint(),
&mut self.panels,
view.picker_diff().and_then(|diff| {
diff.comparison.as_ref().map(|comparison| {
(comparison.files.as_slice(), &diff.reviewed_files)
})
}),
);
}
});
if let Some(geometry) = &mut self.panels.diff {
geometry.regions[0] = (panel, Focus::Files);
editor.set_diff_pane_bounds(
@@ -827,6 +832,44 @@ impl BufferViewports {
}
}
}
if let Some(panel) = editor.review_panel() {
let source = editor.pane_origin(panel);
let area = self
.rects
.iter()
.find(|(id, _)| Some(*id) == source)
.map_or(full_workspace, |(_, area)| *area);
let area = floating_panel(area);
self.rects.insert(0, (panel, area));
self.activate(panel, editor);
self.panels.workspace_managed = true;
editor.with_render_pane(panel, |view| {
frame.render_widget(Clear, area);
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan))
.title(display_text(
view.review_panel_title().unwrap_or("Local review"),
));
let content = block.inner(area);
frame.render_widget(block, area);
self.activate_document(view.buffer_id());
let (top, left) = self.offsets.entry(view.buffer_id()).or_default();
let syntax = self.syntax.entry(view.buffer_id()).or_default();
draw_document(
frame,
view,
content,
full_workspace,
top,
left,
syntax,
&mut self.document,
&mut self.panels,
);
});
self.panels.workspace_managed = false;
}
if let Some(diff) = editor.diff().filter(|diff| diff.phase != Phase::View) {
draw_diff(
frame,
@@ -1097,10 +1140,18 @@ impl BufferViewports {
return true;
}
let focused = session.editor().pane_id();
let Some(target) = self.rects.iter().find_map(|(id, area)| {
let target = self.rects.iter().find_map(|(id, area)| {
area.contains(Position::new(mouse.column, mouse.row))
.then_some(*id)
}) else {
});
let target = if let Some(panel) = session.editor().review_panel() {
if target != Some(panel) {
return true;
}
panel
} else if let Some(target) = target {
target
} else {
return false;
};
if let Some(resolver) = session
@@ -1729,11 +1780,13 @@ fn chrome_items<'a>(
continue;
}
let mut text = match name.as_str() {
"mode" => Cow::Owned(if editor.visual_linewise() {
"VISUAL LINE".into()
} else {
editor.mode().to_string()
}),
"mode" => Cow::Owned(
if editor.mode() == Mode::Visual && editor.visual_linewise() {
"VISUAL LINE".into()
} else {
editor.mode().to_string()
},
),
"path" => Cow::Owned(chrome_title(
"{path}{modified}",
editor.tab_index() + 1,
@@ -13962,7 +14015,7 @@ end
editor.apply_diff_result(
id,
Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison {
root: std::path::PathBuf::new(),
root: editor.project_root().to_path_buf(),
editable_paths: Default::default(),
before: "baseline".into(),
after: "historic".into(),
@@ -13983,6 +14036,143 @@ end
terminal.get_cursor_position().unwrap()
}
#[test]
fn command_input_keeps_characterwise_and_linewise_selection_highlights() {
for context in ["document", "split", "unified"] {
for selector in ['v', 'V'] {
let mut editor = if context == "document" {
let mut editor = Editor::open(None, vec![]).unwrap();
editor
.apply(vec![crate::editor::Effect::Replace(
"alpha beta\nsecond line".into(),
)])
.unwrap();
editor
} else {
let mut editor = readonly_diff_editor(syntax_diff_file(
"file.txt",
"alpha beta\nold line",
"alpha beta\nnew line",
));
if context == "unified" {
editor.execute("diff.view.toggle");
}
editor.handle(Input::Char('\t'));
editor
};
let mut terminal =
Terminal::new(ratatui::backend::TestBackend::new(140, 24)).unwrap();
let mut views = BufferViewports::default();
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
let source = terminal.get_cursor_position().unwrap();
editor.handle(Input::Char(selector));
editor.handle(Input::Right);
let ranges = editor.selections();
editor.handle(Input::Char(':'));
for ch in "diff.comment".chars() {
editor.handle(Input::Char(ch));
}
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
assert_eq!(editor.mode(), Mode::Command);
assert_eq!(editor.selections(), ranges);
let buffer = terminal.backend().buffer();
assert_eq!(
buffer[(source.x, source.y)].bg,
Color::Blue,
"{context} {selector}"
);
assert_eq!(
buffer[(source.x + if selector == 'V' { 9 } else { 1 }, source.y)].bg,
Color::Blue,
"{context} {selector}"
);
editor.handle(Input::Escape);
assert_eq!(editor.selections(), ranges);
}
}
}
#[test]
fn review_editor_floats_over_the_diff_without_changing_workspace_layout() {
for unified in [false, true] {
let mut editor = readonly_diff_editor(syntax_diff_file(
"file.txt",
"old first\nold last",
"new first\nnew last",
));
if unified {
editor.execute("diff.view.toggle");
}
editor.handle(Input::Char('\t'));
let owner = editor.pane_id();
let source_text = editor.context().text;
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(160, 30)).unwrap();
let mut views = BufferViewports::default();
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
let source_area = views
.rects
.iter()
.find(|(pane, _)| *pane == owner)
.unwrap()
.1;
editor.execute("diff.comment");
let panel = editor.review_panel().expect("focused floating review");
assert_eq!(editor.pane_layout().len(), 1);
assert!(!editor.pane_layout().contains(panel));
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
assert_eq!(views.rects[0], (panel, floating_panel(source_area)));
assert_eq!(
views
.rects
.iter()
.find(|(pane, _)| *pane == owner)
.unwrap()
.1,
source_area
);
let area = views.rects[0].1;
let cursor = terminal.get_cursor_position().unwrap();
assert!(area.contains(cursor));
for input in [
Input::Char('i'),
Input::Paste("Retained draft".into()),
Input::Escape,
] {
editor.handle(input);
}
assert_eq!(editor.context().text, "Retained draft");
assert_eq!(editor.review_panel(), Some(panel));
editor.handle(Input::Escape);
assert!(editor.review_panel().is_none());
assert_eq!(editor.pane_id(), owner);
assert_eq!(editor.context().text, source_text);
editor.execute("diff.review.edit 1");
assert_eq!(
editor.context().text,
"Retained draft",
"{}",
editor.message()
);
assert_eq!(editor.review_panel(), Some(panel));
editor.execute("tab.new");
editor.execute("tab.previous");
editor.execute("diff.review.edit 1");
assert_eq!(editor.review_panel(), Some(panel));
assert_eq!(editor.context().text, "Retained draft");
editor.execute("pane.close");
assert!(editor.review_panel().is_none());
assert_eq!(editor.pane_layout().len(), 1);
}
}
#[test]
fn scope_projection_keeps_unified_wrapped_caret_and_selection_source_only() {
let before = format!(