fix: preserve selector text input and close on escape

This commit is contained in:
Valdemar Lorenzen
2026-10-12 13:08:56 +02:00
parent 684ba46a2c
commit 7bc5eab22f
9 changed files with 189 additions and 124 deletions
+1 -1
View File
@@ -36,7 +36,7 @@ Vamp starts in Normal mode. Press `i`, type text, then Esc to return to Normal.
| Paste in Normal | `p`/`P` after/before the caret or row for linewise fragments; system clipboard by default | | Paste in Normal | `p`/`P` after/before the caret or row for linewise fragments; system clipboard by default |
| Command palette | Ctrl+p; type to filter, arrows select, Enter accepts, Esc cancels | | Command palette | Ctrl+p; type to filter, arrows select, Enter accepts, Esc cancels |
| Floating/docked explorer in Normal or Visual | Space then `o` / Space then `e` | | Floating/docked explorer in Normal or Visual | Space then `o` / Space then `e` |
| Repository diff in Normal | `:diff`; choose Before then After, Tab changes category, type to search; Working checkout After is editable; `:diff.close` exits | | Repository diff in Normal | `:diff`; choose Before then After, Tab changes category, type to search; Esc closes the selector; Working checkout After is editable; `:diff.close` exits the comparison |
Space is the default leader: press its suffix keys in order, not simultaneously. The [editing guide](docs/editing.md) contains the complete key table and behavior reference. Space is the default leader: press its suffix keys in order, not simultaneously. The [editing guide](docs/editing.md) contains the complete key table and behavior reference.
+2 -2
View File
@@ -8,8 +8,8 @@
- [x] diff view: add character-wise change highlight (enabled by default). - [x] diff view: add character-wise change highlight (enabled by default).
- [x] add tabs: tab support, editor action to open a buffer in a new tab, default tab navigation is `ctrl+option+h/l`, close tab action. - [x] add tabs: tab support, editor action to open a buffer in a new tab, default tab navigation is `ctrl+option+h/l`, close tab action.
- per-tab pane layouts, views, explorers, and comparisons; shared buffers, tab bar, and guarded close/force-close actions. - per-tab pane layouts, views, explorers, and comparisons; shared buffers, tab bar, and guarded close/force-close actions.
- [ ] make diff selector close on escape. - [x] make diff selector close on escape.
- [ ] `:` currently overrides the diff selector input (and maybe other input fields) and spawns the command input on top. this is of course not intended behavior and should be fixed. - [x] `:` currently overrides the diff selector input (and maybe other input fields) and spawns the command input on top. this is of course not intended behavior and should be fixed.
- [ ] diff view: lsp actions should act as they normally would. if an lsp action would open another file, then the file should be opened in another tab outside the diff view - if the referenced file/symbol is part of the changes in the diff, it should just be opened in the diff view. - [ ] diff view: lsp actions should act as they normally would. if an lsp action would open another file, then the file should be opened in another tab outside the diff view - if the referenced file/symbol is part of the changes in the diff, it should just be opened in the diff view.
- this will require tab support. - this will require tab support.
- [ ] diff view: change diff view modes; choose between split and unified diff view (split by default). - [ ] diff view: change diff view modes; choose between split and unified diff view (split by default).
+3 -1
View File
@@ -199,7 +199,9 @@ config.bind "ctrl+h", nil, mode: config.modes.tree
The diff's changed-file explorer and floating picker use these same tree and selection contexts, including remaps and disabled keys. Enter opens the selected file's comparison; Working checkout After reuses its editable buffer. The catalog contains only changed paths, with A/M/D/R status markers, rather than a disk scan. The diff's changed-file explorer and floating picker use these same tree and selection contexts, including remaps and disabled keys. Enter opens the selected file's comparison; Working checkout After reuses its editable buffer. The catalog contains only changed paths, with A/M/D/R status markers, rather than a disk scan.
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. Close the comparison explicitly with `:diff.close`, its palette action, or the generated action value below. `config.core.diff` remains the opening action and also exposes its immutable `close` child; Escape never closes a comparison. 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 also exposes its immutable `close` child.
Focused selector/filter input receives printable text before Normal-mode action bindings, so `:` stays query text instead of opening Command input. Explicit tree/selection navigation overrides remain effective. Control-key editor action bindings and Ctrl+p remain available.
```ruby ```ruby
config.bind "leader d q", config.core.diff.close config.bind "leader d q", config.core.diff.close
+4 -2
View File
@@ -295,7 +295,9 @@ The floating selector has **Revisions / Bookmarks** tabs for jj, or **Commits /
| 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 |
| Enter | Choose the entry | | Enter | Choose the entry |
| Esc | Cancel loading; After → return to Before; Before → clear query without closing | | Esc | Close either selector stage, cancel outstanding work, and restore the source buffer without discarding edits |
Printable input belongs to the focused query, including `:`, `_`, and `|`; Normal-mode command or pane bindings cannot steal it. Explicit selection-navigation bindings still take priority. Use Ctrl+p to select an action, or configure a control-key action binding, when you need an editor action while the selector is focused.
The selector initially lists local revisions and known tracking refs **without contacting remotes**. **Load refs from origin** (or another configured remote) in Bookmarks/Branches/Tags lists advertised references, including refs never fetched locally, without advancing the selection stage. Selecting a reference such as `some-bookmark@origin` reuses available content or fetches the missing selected reference into a temporary comparison repository. **Load history from origin** in Revisions/Commits explicitly imports searchable remote history. Network operations use the repository's existing credentials; errors stay in the selector. They do not update active refs, snapshot jj's working copy, or change the checkout. The selector initially lists local revisions and known tracking refs **without contacting remotes**. **Load refs from origin** (or another configured remote) in Bookmarks/Branches/Tags lists advertised references, including refs never fetched locally, without advancing the selection stage. Selecting a reference such as `some-bookmark@origin` reuses available content or fetches the missing selected reference into a temporary comparison repository. **Load history from origin** in Revisions/Commits explicitly imports searchable remote history. Network operations use the repository's existing credentials; errors stay in the selector. They do not update active refs, snapshot jj's working copy, or change the checkout.
@@ -331,7 +333,7 @@ Diff backgrounds blend the terminal's default background with its red/green pale
Missing counterparts use adjacent muted `╱` diagonals (U+2571, box drawings light diagonal) on the normal terminal background, one per column. Unlike the text slash `/`, this glyph is designed to reach cell corners so diagonals can join end to end across successive rows. Pixel-perfect joins depend on the terminal's font and line-spacing rendering. Actual empty source lines retain their normal/change background and are not hatched. Missing counterparts use adjacent muted `╱` diagonals (U+2571, box drawings light diagonal) on the normal terminal background, one per column. Unlike the text slash `/`, this glyph is designed to reach cell corners so diagonals can join end to end across successive rows. Pixel-perfect joins depend on the terminal's font and line-spacing rendering. Actual empty source lines retain their normal/change background and are not hatched.
In Normal mode, Tab/Shift+Tab cycles focus between Files and the visible text panes, skipping absent panes. Switching Before/After synchronizes the primary cursor by aligned diff row and display column. A missing counterpart uses the nearest visible source row; shorter lines clamp the column while retaining the preferred display column for the return trip. Vertical wheel input scrolls the visible panes without moving the focused text caret; switching sides by vertical wheel also synchronizes the cursor. Over Files it navigates visible tree rows without opening them. Horizontal wheel input in either split synchronizes the Before/After horizontal offsets, bounded by the longer side, without changing focus, cursor, or selections; over Files it is consumed. Added/deleted files retain single-pane scrolling. Escape cancels modes, prompts, or filtering, or returns picker focus to the last bound text pane; **it never closes the diff**. Use **`:diff.close`**, find `diff.close` in the palette, or bind **`config.core.diff.close`** to leave the comparison explicitly. The dotted command is intentional; there is no `:diffclose` alias. In Normal mode, Tab/Shift+Tab cycles focus between Files and the visible text panes, skipping absent panes. Switching Before/After synchronizes the primary cursor by aligned diff row and display column. A missing counterpart uses the nearest visible source row; shorter lines clamp the column while retaining the preferred display column for the return trip. Vertical wheel input scrolls the visible panes without moving the focused text caret; switching sides by vertical wheel also synchronizes the cursor. Over Files it navigates visible tree rows without opening them. Horizontal wheel input in either split synchronizes the Before/After horizontal offsets, bounded by the longer side, without changing focus, cursor, or selections; over Files it is consumed. Added/deleted files retain single-pane scrolling. While a comparison is open, Escape cancels modes, prompts, or filtering, or returns picker focus to the last bound text pane; it leaves the comparison open. Use **`:diff.close`**, find `diff.close` in the palette, or bind **`config.core.diff.close`** to leave the comparison explicitly. The dotted command is intentional; there is no `:diffclose` alias.
Both diff panes apply available semantic tokens from their own buffer, independently of keyboard focus. Historical snapshots never borrow the working buffer's tokens; live After tokens must match the displayed text revision. Missing or invalidated tokens leave Tree-sitter syntax colors intact. Semantic colors and modifiers preserve the change backgrounds. Both diff panes apply available semantic tokens from their own buffer, independently of keyboard focus. Historical snapshots never borrow the working buffer's tokens; live After tokens must match the displayed text revision. Missing or invalidated tokens leave Tree-sitter syntax colors intact. Semantic colors and modifiers preserve the change backgrounds.
+59 -63
View File
@@ -164,12 +164,6 @@ pub enum DisplayRow {
Fold { start: usize, end: usize }, Fold { start: usize, end: usize },
} }
#[derive(Debug)]
struct SelectorPosition {
category: usize,
query: String,
selected: RevisionTarget,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum RequestKind { enum RequestKind {
Catalog, Catalog,
@@ -214,7 +208,6 @@ pub struct DiffState {
request_kind: Option<RequestKind>, request_kind: Option<RequestKind>,
visible: Vec<usize>, visible: Vec<usize>,
search_labels: Vec<String>, search_labels: Vec<String>,
before_position: Option<SelectorPosition>,
max_before: usize, max_before: usize,
max_after: usize, max_after: usize,
after_widths: Vec<usize>, after_widths: Vec<usize>,
@@ -255,7 +248,6 @@ impl DiffState {
request_kind: Some(RequestKind::Catalog), request_kind: Some(RequestKind::Catalog),
visible: Vec::new(), visible: Vec::new(),
search_labels: Vec::new(), search_labels: Vec::new(),
before_position: None,
max_before: 0, max_before: 0,
max_after: 0, max_after: 0,
after_widths: Vec::new(), after_widths: Vec::new(),
@@ -468,44 +460,20 @@ impl DiffState {
} }
} }
/// Handles modal input; closing is an explicit editor action. /// Handles selector filtering and comparison navigation; the editor owns selector closing.
pub(crate) fn handle(&mut self, input: Input) { pub(crate) fn handle(&mut self, input: Input) {
if input == Input::Escape { if input == Input::Escape {
if self.phase == Phase::View if self.phase == Phase::View
&& self.focus == Focus::Files && self.focus == Focus::Files
&& let Some(files) = &mut self.files && let Some(files) = &mut self.files
&& files.searching
&& files.view == FileView::Tree
{ {
if files.searching && files.view == FileView::Tree {
files.finish_search(); files.finish_search();
return; } else {
}
if self.phase == Phase::View {
if self.focus == Focus::Files
&& let Some(files) = &mut self.files
{
files.focused = false; files.focused = false;
self.files_visible = files.presentation == FilePresentation::Docked; self.files_visible = files.presentation == FilePresentation::Docked;
self.focus = self.first_text_focus(); self.focus = self.first_text_focus();
} }
return;
}
self.pending = None;
self.request_kind = None;
self.loading = false;
self.error = None;
if self.phase == Phase::Before {
self.query.clear();
self.refilter();
return;
}
self.phase = Phase::Before;
self.after = None;
if let Some(position) = self.before_position.take() {
self.category = position.category;
self.query = position.query;
self.refilter();
self.select_target(&position.selected);
} }
return; return;
} }
@@ -562,11 +530,6 @@ impl DiffState {
} else if let RevisionTarget::ListRemote(remote) = &revision.target { } else if let RevisionTarget::ListRemote(remote) = &revision.target {
self.queue(Operation::ListRemote(remote.clone())); self.queue(Operation::ListRemote(remote.clone()));
} else if self.phase == Phase::Before { } else if self.phase == Phase::Before {
self.before_position = Some(SelectorPosition {
category: self.category,
query: self.query.clone(),
selected: revision.target.clone(),
});
self.before = Some(revision); self.before = Some(revision);
self.after = None; self.after = None;
self.phase = Phase::After; self.phase = Phase::After;
@@ -2106,32 +2069,66 @@ mod tests {
} }
#[test] #[test]
fn after_escape_restores_before_query_and_selected_revision_and_cancels_request() { fn selector_escape_closes_each_stage_and_rejects_late_results() {
for after in [false, true] {
let mut editor = editor();
editor
.apply(vec![Effect::Replace("unsaved source".into())])
.unwrap();
let buffer = editor.buffer_id();
open(&mut editor);
let request = if after {
Some(choose_pair(&mut editor))
} else {
text(&mut editor, "query");
None
};
editor.handle(Input::Escape);
assert!(editor.diff().is_none());
assert_eq!(editor.buffer_id(), buffer);
assert_eq!(editor.lines(), ["unsaved source"]);
assert!(editor.dirty());
if let Some(id) = request {
assert!(!editor.apply_diff_result(id, Ok(Outcome::Comparison(comparison()))));
assert!(editor.diff().is_none());
}
}
}
#[test]
fn selector_printable_input_precedes_normal_bindings_but_not_selection_overrides() {
for after in [false, true] {
let mut editor = editor(); let mut editor = editor();
open(&mut editor); open(&mut editor);
editor.handle(Input::Char('\t')); if after {
text(&mut editor, "topic");
editor.handle(Input::Down);
editor.handle(Input::Enter); editor.handle(Input::Enter);
}
editor
.bind("normal", "x", Some("core.command".into()))
.unwrap();
for ch in ":_| x".chars() {
editor.handle(Input::Char(ch));
assert_eq!(editor.mode(), Mode::Normal);
assert_eq!(editor.pane_layout().len(), 1);
}
assert_eq!(editor.diff().unwrap().query, ":_| x");
editor.handle(Input::Ctrl('u'));
editor
.bind("selection", ":", Some("core.down".into()))
.unwrap();
editor.handle(Input::Char(':'));
assert_eq!(editor.diff().unwrap().selected, 1);
assert!(editor.diff().unwrap().query.is_empty()); assert!(editor.diff().unwrap().query.is_empty());
editor.handle(Input::Enter); }
let (id, _) = editor.take_diff_request().unwrap(); let mut editor = editor();
open(&mut editor);
editor.handle(Input::Ctrl('p'));
editor.handle(Input::Char(':'));
assert_eq!(editor.palette().unwrap().query, ":");
assert_eq!(editor.mode(), Mode::Normal);
editor.handle(Input::Escape); editor.handle(Input::Escape);
let state = editor.diff().unwrap(); assert!(editor.palette().is_none());
assert_eq!(state.phase, Phase::Before); assert!(editor.diff().is_some());
assert_eq!(state.category, 1);
assert_eq!(state.query, "topic");
assert_eq!(state.selected, 1);
assert!(!state.loading);
assert!(!editor.apply_diff_result(id, Ok(Outcome::Comparison(comparison()))));
editor.handle(Input::Enter);
assert_eq!(editor.diff().unwrap().phase, Phase::After);
editor.handle(Input::Escape);
editor.handle(Input::Escape);
assert_eq!(editor.diff().unwrap().phase, Phase::Before);
assert!(editor.diff().unwrap().query.is_empty());
editor.execute("diff.close");
assert!(editor.diff().is_none());
} }
#[test] #[test]
@@ -2560,8 +2557,7 @@ mod tests {
buffers buffers
); );
editor.handle(Input::Escape); editor.handle(Input::Escape);
assert!(editor.diff().is_some()); assert!(editor.diff().is_none());
editor.execute("diff.close");
assert_eq!(editor.mode(), Mode::Normal); assert_eq!(editor.mode(), Mode::Normal);
open(&mut editor); open(&mut editor);
let id = choose_pair(&mut editor); let id = choose_pair(&mut editor);
+103 -42
View File
@@ -2628,6 +2628,32 @@ impl Editor {
} }
fn handle_diff(&mut self, input: Input) -> Option<Invocation> { fn handle_diff(&mut self, input: Input) -> Option<Invocation> {
if self.palette.is_some()
|| self.diff_prompt_mode.is_some()
|| self.startup_diagnostic.is_some()
|| self.hover.is_some()
{
return self.handle_input(input);
}
let phase = self.diff.as_ref().expect("active diff").phase;
let files_focused =
phase == Phase::View && self.diff.as_ref().unwrap().focus == Focus::Files;
if phase != Phase::View && input == Input::Escape {
return self.execute_action(Action::DiffClose.entry(), "");
}
let text_focused = phase != Phase::View
|| (files_focused
&& self
.diff
.as_ref()
.and_then(|diff| diff.files.as_ref())
.is_some_and(|files| files.searching || files.view == FileView::List));
if text_focused && matches!(input, Input::Char(ch) if !ch.is_control()) {
if !self.handle_selection_binding(&input) {
self.diff.as_mut().unwrap().handle(input);
}
return None;
}
if self if self
.bindings .bindings
.get(&self.binding_mode()) .get(&self.binding_mode())
@@ -2655,9 +2681,7 @@ impl Editor {
{ {
return self.handle_input(input); return self.handle_input(input);
} }
if self.palette.is_some() if (self.diff_interacting() && self.active.view.mode != Mode::Normal)
|| self.diff_prompt_mode.is_some()
|| (self.diff_interacting() && self.active.view.mode != Mode::Normal)
|| self.count.is_some() || self.count.is_some()
|| self.sequence_pending().is_some() || self.sequence_pending().is_some()
|| self.g_pending || self.g_pending
@@ -2666,9 +2690,6 @@ impl Editor {
{ {
return self.handle_input(input); return self.handle_input(input);
} }
let phase = self.diff.as_ref().expect("active diff").phase;
let files_focused =
phase == Phase::View && self.diff.as_ref().unwrap().focus == Focus::Files;
if self.diff_interacting() { if self.diff_interacting() {
if matches!(input, Input::Char('[' | ']')) { if matches!(input, Input::Char('[' | ']')) {
let focus = self.diff_buffer_focus().unwrap(); let focus = self.diff_buffer_focus().unwrap();
@@ -2704,29 +2725,7 @@ impl Editor {
if files_focused && self.handle_tree_binding(&input) { if files_focused && self.handle_tree_binding(&input) {
return None; return None;
} }
if !self.diff_interacting() { if !self.diff_interacting() && self.handle_selection_binding(&input) {
match self.selection_bindings.get(&input) {
Some(Binding::Action(Action::Down)) => {
self.diff.as_mut().unwrap().navigate(true);
return None;
}
Some(Binding::Action(Action::Up)) => {
self.diff.as_mut().unwrap().navigate(false);
return None;
}
Some(Binding::Disabled) => return None,
_ => {}
}
}
if files_focused
&& matches!(input, Input::Char(ch) if !ch.is_control())
&& self
.diff
.as_ref()
.and_then(|diff| diff.files.as_ref())
.is_some_and(|files| files.searching || files.view == FileView::List)
{
self.diff.as_mut().unwrap().handle(input);
return None; return None;
} }
if self.sequence_pending().is_some() if self.sequence_pending().is_some()
@@ -2758,7 +2757,6 @@ impl Editor {
return self.handle_input(input); return self.handle_input(input);
} }
let escape = input == Input::Escape; let escape = input == Input::Escape;
let back = input == Input::Escape && phase != Phase::View;
let return_focus = files_focused let return_focus = files_focused
.then(|| self.diff_bound.map(|bound| bound.2)) .then(|| self.diff_bound.map(|bound| bound.2))
.flatten(); .flatten();
@@ -2772,9 +2770,6 @@ impl Editor {
diff.focus = focus; diff.focus = focus;
diff.normalize_focus(); diff.normalize_focus();
} }
if back {
self.outstanding_diff_request = None;
}
None None
} }
@@ -4286,6 +4281,10 @@ impl Editor {
} else { } else {
palette.selected - 1 palette.selected - 1
}; };
} else if self.palette.is_none()
&& let Some(diff) = &mut self.diff
{
diff.navigate(next);
} }
true true
} }
@@ -8721,6 +8720,70 @@ mod tests {
assert_eq!(editor.active.view.targets, targets); assert_eq!(editor.active.view.targets, targets);
} }
#[test]
fn focused_text_fields_keep_printable_normal_bindings_as_text() {
let directory = Directory::new();
fs::write(directory.path("sample.txt"), "text").unwrap();
for field in [
"floating",
"docked",
"diff_tree",
"diff_list",
"palette",
"command",
"search",
] {
let mut editor = editor();
editor.project_root = directory.0.clone();
editor
.bind("normal", "x", Some("core.pane.split_vertical".into()))
.unwrap();
match field {
"floating" => editor.open_file_picker(FilePresentation::Floating),
"docked" => {
editor.open_file_picker(FilePresentation::Docked);
editor.handle(Input::Char('i'));
}
"diff_tree" | "diff_list" => {
historical_diff(
&mut editor,
&directory.0,
vec!["before".into()],
vec!["after".into()],
);
if field == "diff_list" {
editor.set_file_explorer_view("list").unwrap();
}
editor.open_file_picker(FilePresentation::Docked);
if field == "diff_tree" {
editor.handle(Input::Char('i'));
}
}
"palette" => {
editor.handle(Input::Ctrl('p'));
}
"command" => {
editor.handle(Input::Char(':'));
}
"search" => {
editor.handle(Input::Char('/'));
}
_ => unreachable!(),
}
let document = editor.lines().to_vec();
editor.handle(Input::Char(':'));
editor.handle(Input::Char('x'));
let query = match field {
"palette" => editor.palette().unwrap().query.as_str(),
"command" | "search" => editor.command_line(),
_ => editor.file_picker().unwrap().query.as_str(),
};
assert_eq!(query, ":x", "{field}");
assert_eq!(editor.pane_layout().len(), 1, "{field}");
assert_eq!(editor.lines(), document, "{field}");
}
}
#[test] #[test]
fn empty_notification_does_not_swallow_escape_but_cursor_diagnostic_does() { fn empty_notification_does_not_swallow_escape_but_cursor_diagnostic_does() {
let mut editor = editor(); let mut editor = editor();
@@ -9637,12 +9700,12 @@ mod tests {
] { ] {
editor.handle(input); editor.handle(input);
} }
editor.handle(Input::Escape);
assert!(editor.diff().is_some());
type_text(&mut editor, ":unknown\n"); type_text(&mut editor, ":unknown\n");
editor.handle(Input::Ctrl('p')); editor.handle(Input::Ctrl('p'));
editor.handle(Input::Escape); editor.handle(Input::Escape);
editor.execute("diff.close"); assert!(editor.diff().is_some());
editor.handle(Input::Escape);
assert!(editor.diff().is_none());
assert_eq!(editor.mode(), Mode::Visual); assert_eq!(editor.mode(), Mode::Visual);
assert_eq!(editor.carets(), carets); assert_eq!(editor.carets(), carets);
assert_eq!(editor.selections(), selections); assert_eq!(editor.selections(), selections);
@@ -9665,14 +9728,12 @@ mod tests {
let caret = editor.cursor(); let caret = editor.cursor();
editor.execute("diff"); editor.execute("diff");
editor.handle(Input::Paste("ignored".into())); editor.handle(Input::Paste("ignored".into()));
editor.handle(Input::Escape);
assert!(editor.diff().is_some());
type_text(&mut editor, ":unfinished"); type_text(&mut editor, ":unfinished");
editor.handle(Input::Escape);
editor editor
.apply(vec![Effect::Message("message without edits".into())]) .apply(vec![Effect::Message("message without edits".into())])
.unwrap(); .unwrap();
editor.execute("diff.close"); editor.handle(Input::Escape);
assert!(editor.diff().is_none());
assert_eq!(editor.mode(), Mode::Insert); assert_eq!(editor.mode(), Mode::Insert);
assert_eq!(editor.cursor(), caret); assert_eq!(editor.cursor(), caret);
type_text(&mut editor, "finish"); type_text(&mut editor, "finish");
@@ -9822,7 +9883,7 @@ mod tests {
editor.handle(Input::Char('q')); editor.handle(Input::Char('q'));
assert_eq!(editor.diff().unwrap().query, "q"); assert_eq!(editor.diff().unwrap().query, "q");
editor.handle(Input::Escape); editor.handle(Input::Escape);
assert_eq!(editor.diff().unwrap().query, ""); assert!(editor.diff().is_none());
assert!(editor.notification_visible()); assert!(editor.notification_visible());
} }
@@ -10406,7 +10467,7 @@ mod tests {
editor.execute("diff"); editor.execute("diff");
type_text(&mut editor, ":diffclose\n"); type_text(&mut editor, ":diffclose\n");
assert!(editor.diff().is_some()); assert!(editor.diff().is_some());
type_text(&mut editor, ":diff.close\n"); editor.execute("diff.close");
assert!(editor.diff().is_none()); assert!(editor.diff().is_none());
} }
+2 -2
View File
@@ -51,9 +51,9 @@ Diff: :diff (Normal); choose Before then After; Working checkout After edits liv
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
Type to search IDs/refs; navigation skips omitted lines without expanding excerpts Selector queries own printable keys, including :; text navigation skips omitted lines
Normal [/] explicitly expands context; Insert brackets remain literal text Normal [/] explicitly expands context; Insert brackets remain literal text
:diff.close exits explicitly; Esc cancels modes/prompts/filtering without closing the diff 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
+4 -2
View File
@@ -965,7 +965,7 @@ end
config.bind "D", config.core.buffer_delete config.bind "D", config.core.buffer_delete
config.bind "Z", config.core.buffer_force_delete config.bind "Z", config.core.buffer_force_delete
config.bind "R", config.core.diff config.bind "R", config.core.diff
config.bind "T", config.core.diff.close config.bind "ctrl+t", config.core.diff.close
config.bind "H", config.core.word_wrap.toggle config.bind "H", config.core.word_wrap.toggle
raise "mutable diff actions" unless config.core.frozen? && config.core.diff.frozen? && raise "mutable diff actions" unless config.core.frozen? && config.core.diff.frozen? &&
config.core.diff.close.frozen? && config.core.diff.name.frozen? && config.core.diff.close.frozen? && config.core.diff.name.frozen? &&
@@ -982,8 +982,10 @@ end"#,
assert!(!review.editor().word_wrap()); assert!(!review.editor().word_wrap());
review.handle(Input::Char('R')); review.handle(Input::Char('R'));
review.handle(Input::Escape); review.handle(Input::Escape);
assert!(review.editor().diff().is_none());
review.handle(Input::Char('R'));
assert!(review.editor().diff().is_some()); assert!(review.editor().diff().is_some());
review.handle(Input::Char('T')); review.handle(Input::Ctrl('t'));
assert!(review.editor().diff().is_none()); assert!(review.editor().diff().is_none());
for (key, dirty, quit, saved) in [ for (key, dirty, quit, saved) in [
('Q', false, true, false), ('Q', false, true, false),
+7 -5
View File
@@ -4833,10 +4833,6 @@ mod tests {
diff.handle(Input::Enter); diff.handle(Input::Enter);
render_diff(&mut terminal, &diff, &mut panels); render_diff(&mut terminal, &diff, &mut panels);
assert!(screen_text(&terminal).contains("Choose After · Before: commit-19")); assert!(screen_text(&terminal).contains("Choose After · Before: commit-19"));
diff.handle(Input::Escape);
render_diff(&mut terminal, &diff, &mut panels);
assert!(screen_text(&terminal).contains("> needle"));
assert!(screen_text(&terminal).contains("> commit-19"));
diff.handle(Input::Ctrl('u')); diff.handle(Input::Ctrl('u'));
diff.handle(Input::Char('\t')); diff.handle(Input::Char('\t'));
render_diff(&mut terminal, &diff, &mut panels); render_diff(&mut terminal, &diff, &mut panels);
@@ -5649,7 +5645,6 @@ mod tests {
fn floating_inputs_share_workspace_geometry_across_documents_splits_and_diffs() { fn floating_inputs_share_workspace_geometry_across_documents_splits_and_diffs() {
for context in ["document", "split", "diff", "split-diff", "selector"] { for context in ["document", "split", "diff", "split-diff", "selector"] {
for prefix in [':', '/'] { for prefix in [':', '/'] {
// A selector's slash belongs to its revision filter, not native search.
if context == "selector" && prefix == '/' { if context == "selector" && prefix == '/' {
continue; continue;
} }
@@ -5670,7 +5665,14 @@ mod tests {
editor.apply_diff_result(id, Ok(crate::diff::Outcome::Catalog(diff_catalog()))); editor.apply_diff_result(id, Ok(crate::diff::Outcome::Catalog(diff_catalog())));
} }
editor.set_message("saved notification"); editor.set_message("saved notification");
if context == "selector" {
editor
.bind("normal", "ctrl+t", Some("core.command".into()))
.unwrap();
editor.handle(Input::Ctrl('t'));
} else {
editor.handle(Input::Char(prefix)); editor.handle(Input::Char(prefix));
}
let query = format!("{}TAIL", "界e\u{301}👩‍🚀".repeat(30)); let query = format!("{}TAIL", "界e\u{301}👩‍🚀".repeat(30));
for ch in query.chars() { for ch in query.chars() {
editor.handle(Input::Char(ch)); editor.handle(Input::Char(ch));