feat: add Vim-style word search
This commit is contained in:
@@ -3,7 +3,10 @@
|
||||
- [x] improve the lsp hover floating window:
|
||||
- it should be taller
|
||||
- [x] add support for zig syntax highlighting
|
||||
- [ ] add the `*` action from vim on word to highlights and searches in buffer in one action.
|
||||
- [x] add the `*` action from vim on word to highlights and searches in buffer in one action.
|
||||
- [ ] improve diff view: we should be able to tell what the part of the code we're looking at (any given change hunk) and where it belongs.
|
||||
- i think it would be nice if we in the diff view somehow displayed the function name and/or class name that a hunk belongs to
|
||||
- we should consider the ui before implementing this
|
||||
- [ ] diff view: github integration (code review) - add comments on a pr, add review comments at specific lines of files, request changes, approve pr, etc.
|
||||
- [ ] diff view: local review comments (same features as for github reviews) that allows review of local changes to deliver specific reviews to ai agents used for coding.
|
||||
- design question: would this require a server that the agent can interact with, or would local review files be fine? don't want to use server client stuff, if it would be easier and better to just use local files.
|
||||
|
||||
@@ -231,7 +231,7 @@ Normal `C` / `D` / `Y` change/delete/copy the cursor-to-line-end suffix through
|
||||
| Normal | `jump_back`, `jump_forward` |
|
||||
| Normal | `lsp.definition`, `lsp.diagnostics`, `lsp.hover` |
|
||||
| Insert | `lsp.completion`, `snippet.next`, `snippet.previous` |
|
||||
| Normal | `search` |
|
||||
| Normal | `search`, `search_word` |
|
||||
| Normal, Visual | `paste_before`, `paste_after`, `paste_system_before`, `paste_system_after` |
|
||||
| Normal, Visual | `select`, `select_linewise`, `search_next`, `search_previous` |
|
||||
| Normal, Visual | `repeat_last_edit` |
|
||||
|
||||
+5
-1
@@ -40,7 +40,7 @@ Indicators refresh asynchronously. Typing and undo/redo retain the last complete
|
||||
| Normal | `d<count>w` / `d<count>b` delete forward/backward word spans, e.g. `d2w` / `d2b` |
|
||||
| Normal | `c<count>w` / `c<count>b` cut the same line-local spans as `d` and enter Insert, e.g. `c2w` / `c2b` |
|
||||
| Normal, Visual | `.` repeats the last successful native edit against the current targets; one undo group |
|
||||
| Normal | `/` opens search; `n` finds the next match; `N` (Shift+n) finds the previous match |
|
||||
| Normal | `/` opens search; `*` searches the word under the primary caret; `n`/`N` find the next/previous match |
|
||||
| Normal | `v` starts characterwise Visual; `V` starts linewise Visual; `p`/`P` paste the configured clipboard after/before each caret or row for linewise fragments (system by default); Esc keeps only the primary caret |
|
||||
| Normal, Visual | Ctrl+k clones above; Ctrl+e clones below |
|
||||
| Visual | Cursor/word motions and `n`/`N` extend every selection; `y` copies; `d`/`x`/Delete cuts; `c` changes; `v`/`V` switches shape, the same selector or Esc cancels |
|
||||
@@ -172,6 +172,10 @@ The last recipe is session-wide across buffers, but pending Insert groups and un
|
||||
|
||||
Search is case-sensitive and single-line, with no regular expressions or Unicode normalization. Matches start and end on whole grapheme boundaries, including combining marks and emoji. Next/previous navigation skips the current position and wraps through the buffer; a sole match wraps back to itself. The active match is highlighted yellow.
|
||||
|
||||
Normal `*` searches forward for the primary caret's word and highlights the destination in one action. It uses the same Unicode letters/digits/underscore boundaries as `w`, so `cat` skips `scatter`, `cat_cat`, and `cat2`. If the caret is not on a keyword, it takes the next keyword on that line; with none, it uses the punctuation run under or after the caret as a literal substring. A blank remainder reports no word and preserves the previous search. Counts such as `2*` skip to the requested occurrence; every caret searches the primary word from its own position. This also works in read-only diff text. Bind `config.core.search_word` to remap it.
|
||||
|
||||
`n`/`N` and an empty `/` submission retain the whole-word setting; a nonempty `/` query returns to literal substring search. Cancelling `/` preserves both the previous query and its matching behavior.
|
||||
|
||||
Submitting an empty search repeats the last query. Esc cancels without moving the cursor or replacing the previous query/match. A missing match reports the query, clears the highlight, and leaves the cursor unchanged; searching without a previous query also leaves the cursor unchanged. Search never edits text or adds undo history. Buffer edits and undo/redo clear the highlight but retain the query, so subsequent navigation searches the current text again.
|
||||
|
||||
## Commands, saving, and pasted input
|
||||
|
||||
+84
-15
@@ -506,6 +506,7 @@ core_actions![
|
||||
),
|
||||
("core.command", Action::Command, &[Mode::Normal]),
|
||||
("core.search", Action::Search, &[Mode::Normal]),
|
||||
("core.search_word", Action::SearchWord, &[Mode::Normal]),
|
||||
(
|
||||
"core.search_next",
|
||||
Action::SearchNext,
|
||||
@@ -1263,6 +1264,7 @@ fn default_bindings() -> HashMap<Mode, HashMap<Input, Binding>> {
|
||||
('U', Redo),
|
||||
(':', Command),
|
||||
('/', Search),
|
||||
('*', SearchWord),
|
||||
('p', PasteAfter),
|
||||
('P', PasteBefore),
|
||||
('_', PaneSplitHorizontal),
|
||||
@@ -1572,6 +1574,7 @@ struct ViewState {
|
||||
word_wrap: bool,
|
||||
linewise: bool,
|
||||
search_query: String,
|
||||
search_whole_word: bool,
|
||||
search_match: Option<SearchMatch>,
|
||||
command_line: String,
|
||||
caret_reveal_revision: u64,
|
||||
@@ -1590,6 +1593,7 @@ impl ViewState {
|
||||
word_wrap: false,
|
||||
linewise: false,
|
||||
search_query: String::new(),
|
||||
search_whole_word: false,
|
||||
search_match: None,
|
||||
command_line: String::new(),
|
||||
caret_reveal_revision: 0,
|
||||
@@ -1608,6 +1612,7 @@ impl ViewState {
|
||||
word_wrap: self.word_wrap,
|
||||
linewise: self.linewise,
|
||||
search_query: self.search_query.clone(),
|
||||
search_whole_word: self.search_whole_word,
|
||||
search_match: self.search_match,
|
||||
command_line: self.command_line.clone(),
|
||||
caret_reveal_revision: self.caret_reveal_revision,
|
||||
@@ -8323,8 +8328,9 @@ impl Editor {
|
||||
};
|
||||
self.active.view.command_line.clear();
|
||||
}
|
||||
Action::SearchNext => self.seek_search(true, count),
|
||||
Action::SearchPrevious => self.seek_search(false, count),
|
||||
Action::SearchWord | Action::SearchNext | Action::SearchPrevious => {
|
||||
self.seek_search(action, count);
|
||||
}
|
||||
Action::Select | Action::SelectLinewise => {
|
||||
let linewise = matches!(action, Action::SelectLinewise);
|
||||
if self.active.view.mode == Mode::Visual && self.active.view.linewise == linewise {
|
||||
@@ -8395,8 +8401,9 @@ impl Editor {
|
||||
if searching {
|
||||
if !command.is_empty() {
|
||||
self.active.view.search_query = command;
|
||||
self.active.view.search_whole_word = false;
|
||||
}
|
||||
self.seek_search(true, 1);
|
||||
self.seek_search(Action::SearchNext, 1);
|
||||
} else {
|
||||
return self.execute(&command);
|
||||
}
|
||||
@@ -8405,15 +8412,59 @@ impl Editor {
|
||||
None
|
||||
}
|
||||
|
||||
fn seek_search(&mut self, forward: bool, count: usize) {
|
||||
fn set_word_search(&mut self) -> Option<Cursor> {
|
||||
let cursor = self.cursor();
|
||||
let line = &self.active.lines[cursor.row];
|
||||
let origin = grapheme_byte(line, cursor.column);
|
||||
let tokens = || {
|
||||
line.grapheme_indices(true)
|
||||
.map(|(byte, grapheme)| (byte, word_kind(grapheme, false)))
|
||||
};
|
||||
let Some((byte, kind)) = tokens()
|
||||
.find(|(byte, kind)| *byte >= origin && *kind == WordKind::Keyword)
|
||||
.or_else(|| {
|
||||
tokens().find(|(byte, kind)| *byte >= origin && *kind != WordKind::Whitespace)
|
||||
})
|
||||
else {
|
||||
self.set_message("No word under cursor");
|
||||
return None;
|
||||
};
|
||||
let start = tokens()
|
||||
.rev()
|
||||
.skip_while(|(position, _)| *position >= byte)
|
||||
.take_while(|(_, previous)| *previous == kind)
|
||||
.last()
|
||||
.map_or(byte, |(position, _)| position);
|
||||
let end = tokens()
|
||||
.find(|(position, next)| *position > byte && *next != kind)
|
||||
.map_or(line.len(), |(position, _)| position);
|
||||
self.active.view.search_query = line[start..end].into();
|
||||
self.active.view.search_whole_word = kind == WordKind::Keyword;
|
||||
Some(Cursor {
|
||||
row: cursor.row,
|
||||
column: column_after_byte(line, start),
|
||||
})
|
||||
}
|
||||
|
||||
fn seek_search(&mut self, action: Action, count: usize) {
|
||||
self.reveal_caret();
|
||||
let source = self.jump_location();
|
||||
let primary_origin = if action == Action::SearchWord {
|
||||
Some(match self.set_word_search() {
|
||||
Some(origin) => origin,
|
||||
None => return,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let forward = action != Action::SearchPrevious;
|
||||
self.active.view.search_match = None;
|
||||
if self.active.view.search_query.is_empty() {
|
||||
self.set_message("No previous search");
|
||||
return;
|
||||
}
|
||||
let query = self.active.view.search_query.as_str();
|
||||
let whole_word = self.active.view.search_whole_word;
|
||||
// ponytail: O(buffer graphemes × query bytes) scan; index matches if large buffers need it.
|
||||
let matches: Vec<_> = self
|
||||
.navigable_rows(0, self.active.lines.len())
|
||||
@@ -8423,7 +8474,16 @@ impl Editor {
|
||||
(line[byte..].starts_with(query)
|
||||
&& GraphemeCursor::new(byte + query.len(), line.len(), true)
|
||||
.is_boundary(line, 0)
|
||||
.expect("complete line supplies grapheme context"))
|
||||
.expect("complete line supplies grapheme context")
|
||||
&& (!whole_word
|
||||
|| (line[..byte]
|
||||
.graphemes(true)
|
||||
.next_back()
|
||||
.is_none_or(|g| word_kind(g, false) != WordKind::Keyword)
|
||||
&& line[byte + query.len()..]
|
||||
.graphemes(true)
|
||||
.next()
|
||||
.is_none_or(|g| word_kind(g, false) != WordKind::Keyword))))
|
||||
.then_some((row, byte))
|
||||
})
|
||||
})
|
||||
@@ -8433,9 +8493,14 @@ impl Editor {
|
||||
return;
|
||||
}
|
||||
for (index, target) in self.active.view.targets.iter_mut().enumerate() {
|
||||
let cursor = if index == self.active.view.primary {
|
||||
primary_origin.unwrap_or(target.active)
|
||||
} else {
|
||||
target.active
|
||||
};
|
||||
let origin = (
|
||||
target.active.row,
|
||||
grapheme_byte(&self.active.lines[target.active.row], target.active.column),
|
||||
cursor.row,
|
||||
grapheme_byte(&self.active.lines[cursor.row], cursor.column),
|
||||
);
|
||||
let first = if forward {
|
||||
matches.partition_point(|position| *position <= origin) % matches.len()
|
||||
@@ -11862,6 +11927,17 @@ enum WordKind {
|
||||
EmptyLine,
|
||||
}
|
||||
|
||||
fn word_kind(grapheme: &str, big: bool) -> WordKind {
|
||||
let ch = grapheme.chars().next().expect("graphemes are nonempty");
|
||||
if ch.is_whitespace() {
|
||||
WordKind::Whitespace
|
||||
} else if big || ch == '_' || ch.is_alphanumeric() {
|
||||
WordKind::Keyword
|
||||
} else {
|
||||
WordKind::Punctuation
|
||||
}
|
||||
}
|
||||
|
||||
fn word_tokens(
|
||||
lines: &[String],
|
||||
big: bool,
|
||||
@@ -11869,14 +11945,7 @@ fn word_tokens(
|
||||
lines.iter().enumerate().flat_map(move |(row, line)| {
|
||||
line.grapheme_indices(true)
|
||||
.map(move |(byte, grapheme)| {
|
||||
let ch = grapheme.chars().next().expect("graphemes are nonempty");
|
||||
let kind = if ch.is_whitespace() {
|
||||
WordKind::Whitespace
|
||||
} else if big || ch == '_' || ch.is_alphanumeric() {
|
||||
WordKind::Keyword
|
||||
} else {
|
||||
WordKind::Punctuation
|
||||
};
|
||||
let kind = word_kind(grapheme, big);
|
||||
(row, byte, kind)
|
||||
})
|
||||
.chain(std::iter::once((
|
||||
|
||||
+1
-1
@@ -28,7 +28,7 @@ Normal: i/a insert, I/A insert at first nonblank/end, o/O new line
|
||||
_ splits above/below, | left/right; Option/Alt+h/j/k/l focuses neighboring panes
|
||||
Ctrl+Option/Alt+h/l previous/next tab; :tab.new opens current buffer in another tab
|
||||
Normal/Visual: prefix motions with counts (5j, 2w, 3n); Esc cancels count
|
||||
u undo, U redo, : command, / search, n/N next/previous match
|
||||
u undo, U redo, : command, / search, * search word, n/N next/previous match
|
||||
yy copies whole lines; dd deletes them; cc changes them and enters Insert; counts: 3yy, 2dd, 2cc
|
||||
dw/db cut forward/backward words on the current line; cw/cb cut the same spans and enter Insert
|
||||
Word counts before/after the operator multiply: d2w, 2cw, 2c3b; newlines are preserved
|
||||
|
||||
@@ -120,6 +120,92 @@ fn wait_until(session: &mut EditorSession, ready: impl Fn(&EditorSession) -> boo
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn star_search_keeps_whole_word_counts_history_and_literal_search_distinct() {
|
||||
let fixture = Fixture::new();
|
||||
let text = "cat\nscatter\ncat_cat\ncat2\n2cat\nCat\ncat\ncat\n\n";
|
||||
let mut editor = fixture.editor(text);
|
||||
keys(&mut editor, "l*");
|
||||
assert_eq!(editor.cursor(), Cursor { row: 6, column: 0 });
|
||||
assert_eq!(editor.search_match().unwrap().end_column, 3);
|
||||
keys(&mut editor, "nN2*");
|
||||
assert_eq!(editor.cursor(), Cursor { row: 0, column: 0 });
|
||||
editor.handle(Input::Ctrl('o'));
|
||||
assert_eq!(editor.cursor(), Cursor { row: 6, column: 0 });
|
||||
editor.handle(Input::Char('\t'));
|
||||
assert_eq!(editor.cursor(), Cursor { row: 0, column: 0 });
|
||||
|
||||
keys(&mut editor, "/scatter");
|
||||
editor.handle(Input::Escape);
|
||||
editor.handle(Input::Char('/'));
|
||||
editor.handle(Input::Enter);
|
||||
assert_eq!(editor.cursor(), Cursor { row: 6, column: 0 });
|
||||
keys(&mut editor, "gb*");
|
||||
assert_eq!(editor.cursor(), Cursor { row: 9, column: 0 });
|
||||
keys(&mut editor, "n");
|
||||
assert_eq!(editor.cursor(), Cursor { row: 0, column: 0 });
|
||||
|
||||
keys(&mut editor, "/cat");
|
||||
editor.handle(Input::Enter);
|
||||
assert_eq!(editor.cursor(), Cursor { row: 1, column: 1 });
|
||||
assert_eq!(editor.search_match().unwrap().end_column, 4);
|
||||
assert_eq!(editor.context().text, text);
|
||||
assert!(!editor.dirty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn star_search_uses_unicode_words_and_vim_same_line_fallbacks() {
|
||||
let fixture = Fixture::new();
|
||||
for (text, positioning, row, length) in [
|
||||
("e\u{301}lan\ne\u{301}lanx\ne\u{301}lan", "l", 2, 4),
|
||||
("世界\n世界中\n世界", "l", 2, 2),
|
||||
("snake_case\nsnake_case2\nsnake_case", "3l", 2, 10),
|
||||
("\t-- word\nword", "", 1, 4),
|
||||
("-- word\nword", "", 1, 4),
|
||||
("word --\n--", "$", 1, 2),
|
||||
(" .* \n.*", "", 1, 2),
|
||||
("👩🚀\n👩\n👩🚀", "", 2, 1),
|
||||
("only", "l", 0, 4),
|
||||
] {
|
||||
let mut editor = fixture.editor(text);
|
||||
keys(&mut editor, positioning);
|
||||
editor.handle(Input::Char('*'));
|
||||
assert_eq!(editor.cursor(), Cursor { row, column: 0 }, "{text:?}");
|
||||
assert_eq!(
|
||||
editor.search_match().unwrap().end_column,
|
||||
length,
|
||||
"{text:?}"
|
||||
);
|
||||
keys(&mut editor, "Nn");
|
||||
assert_eq!(editor.cursor(), Cursor { row, column: 0 }, "{text:?}");
|
||||
assert!(!editor.dirty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn star_search_rebinding_moves_all_carets_and_remains_view_local() {
|
||||
let fixture = Fixture::new();
|
||||
let mut session = fixture.session(
|
||||
"cat cat_cat cat cat\ncat cat_cat cat cat",
|
||||
" config.bind '*', nil\n config.bind 'q', config.core.search_word",
|
||||
);
|
||||
session.handle(Input::Ctrl('e'));
|
||||
session_keys(&mut session, "2q");
|
||||
assert_eq!(
|
||||
session.editor().carets(),
|
||||
[Cursor { row: 0, column: 16 }, Cursor { row: 1, column: 16 }]
|
||||
);
|
||||
session_keys(&mut session, "|2N");
|
||||
assert_eq!(session.editor().cursor(), Cursor { row: 1, column: 0 });
|
||||
session_keys(&mut session, "/cat");
|
||||
session.handle(Input::Enter);
|
||||
assert_eq!(session.editor().cursor(), Cursor { row: 1, column: 4 });
|
||||
session.handle(Input::Alt('h'));
|
||||
session_keys(&mut session, "2N");
|
||||
assert_eq!(session.editor().cursor(), Cursor { row: 1, column: 0 });
|
||||
assert!(!session.editor().dirty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn counted_character_seeks_move_every_native_caret_independently() {
|
||||
let fixture = Fixture::new();
|
||||
@@ -352,6 +438,31 @@ fn readonly_before_and_historical_after_seeks_preserve_snapshots_and_folded_exce
|
||||
column: second_x
|
||||
}
|
||||
);
|
||||
session_keys(&mut session, "*");
|
||||
assert_eq!(
|
||||
session.editor().cursor(),
|
||||
Cursor {
|
||||
row: 20,
|
||||
column: 12
|
||||
}
|
||||
);
|
||||
assert_eq!(session.editor().search_match().unwrap().end_column, 13);
|
||||
session_keys(&mut session, "3*N");
|
||||
assert_eq!(
|
||||
session.editor().cursor(),
|
||||
Cursor {
|
||||
row: 18,
|
||||
column: 12
|
||||
}
|
||||
);
|
||||
session_keys(&mut session, "3n");
|
||||
assert_eq!(
|
||||
session.editor().cursor(),
|
||||
Cursor {
|
||||
row: 19,
|
||||
column: second_x
|
||||
}
|
||||
);
|
||||
session_keys(&mut session, "Fxvfx");
|
||||
assert_eq!(
|
||||
session.editor().selections(),
|
||||
|
||||
Reference in New Issue
Block a user