mirror of https://github.com/helix-editor/helix
Merge pull request #1 from ivanrg99/local-search-buffer-fuzzy
Add fuzzy searching. All items (in this case, lines) are shown at once since that's the norm in fuzzy searchers. The unsafe is used to avoid extra memory allocations. If we use the text() method on the Document and get the Rope, we still need to iterate over the lines, but we will have to allocate a String every time so we can trim it, and also add it to the results to be injected later into the Injector. Using this feature in a file with 10000 lines would allocate 10000 times!pull/13053/head
commit
74a4c613e8
|
@ -80,7 +80,8 @@
|
|||
| `search_selection_detect_word_boundaries` | Use current selection as the search pattern, automatically wrapping with `\b` on word boundaries | normal: `` * ``, select: `` * `` |
|
||||
| `make_search_word_bounded` | Modify current search to make it word bounded | |
|
||||
| `global_search` | Global search in workspace folder | normal: `` <space>/ ``, select: `` <space>/ `` |
|
||||
| `local_search` | Local search in buffer | normal: `` <space>l ``, select: `` <space>l `` |
|
||||
| `local_search_grep` | Local search in buffer | normal: `` <space>l ``, select: `` <space>l `` |
|
||||
| `local_search_fuzzy` | Fuzzy local search in buffer | normal: `` <space>L ``, select: `` <space>L `` |
|
||||
| `extend_line` | Select current line, if already selected, extend to another line based on the anchor | |
|
||||
| `extend_line_below` | Select current line, if already selected, extend to next line | normal: `` x ``, select: `` x `` |
|
||||
| `extend_line_above` | Select current line, if already selected, extend to previous line | |
|
||||
|
|
|
@ -379,7 +379,8 @@ impl MappableCommand {
|
|||
search_selection_detect_word_boundaries, "Use current selection as the search pattern, automatically wrapping with `\\b` on word boundaries",
|
||||
make_search_word_bounded, "Modify current search to make it word bounded",
|
||||
global_search, "Global search in workspace folder",
|
||||
local_search, "Local search in buffer",
|
||||
local_search_grep, "Local search in buffer",
|
||||
local_search_fuzzy, "Fuzzy local search in buffer",
|
||||
extend_line, "Select current line, if already selected, extend to another line based on the anchor",
|
||||
extend_line_below, "Select current line, if already selected, extend to next line",
|
||||
extend_line_above, "Select current line, if already selected, extend to previous line",
|
||||
|
@ -2647,8 +2648,8 @@ fn global_search(cx: &mut Context) {
|
|||
cx.push_layer(Box::new(overlaid(picker)));
|
||||
}
|
||||
|
||||
/// Local search in buffer
|
||||
fn local_search(cx: &mut Context) {
|
||||
/// Local grep search in buffer
|
||||
fn local_search_grep(cx: &mut Context) {
|
||||
#[derive(Debug)]
|
||||
struct FileResult {
|
||||
path: PathBuf,
|
||||
|
@ -2696,9 +2697,8 @@ fn local_search(cx: &mut Context) {
|
|||
}),
|
||||
PickerColumn::new("", |item: &FileResult, _config: &LocalSearchConfig| {
|
||||
// extract line content to be displayed in the picker
|
||||
let line_content = item.line_content.clone();
|
||||
// create column value to be displayed in the picker
|
||||
Cell::from(Spans::from(vec![Span::raw(line_content)]))
|
||||
Cell::from(Spans::from(vec![Span::raw(&item.line_content)]))
|
||||
}),
|
||||
];
|
||||
|
||||
|
@ -2889,6 +2889,154 @@ fn local_search(cx: &mut Context) {
|
|||
cx.push_layer(Box::new(overlaid(picker)));
|
||||
}
|
||||
|
||||
fn local_search_fuzzy(cx: &mut Context) {
|
||||
#[derive(Debug)]
|
||||
struct FileResult {
|
||||
path: std::sync::Arc<PathBuf>,
|
||||
line_num: usize,
|
||||
file_contents_byte_start: usize,
|
||||
file_contents_byte_end: usize,
|
||||
}
|
||||
|
||||
struct LocalSearchData {
|
||||
file_contents: String,
|
||||
number_style: Style,
|
||||
}
|
||||
|
||||
let current_document = doc!(cx.editor);
|
||||
let Some(current_document_path) = current_document.path() else {
|
||||
cx.editor.set_error("Failed to get current document path");
|
||||
return;
|
||||
};
|
||||
|
||||
let file_contents = std::fs::read_to_string(current_document_path).unwrap();
|
||||
|
||||
let current_document_path = std::sync::Arc::new(current_document_path.clone());
|
||||
|
||||
let file_results: Vec<FileResult> = file_contents
|
||||
.lines()
|
||||
.enumerate()
|
||||
.filter_map(|(line_num, line)| {
|
||||
if !line.trim().is_empty() {
|
||||
// SAFETY: The offsets will be used to index back into the original `file_contents` String
|
||||
// as a byte slice. Since the `file_contents` will be moved into the `Picker` as part of
|
||||
// `editor_data`, we know that the `Picker` will take ownership of the underlying String,
|
||||
// so it will be valid for displaying the `Span` as long as the user uses the `Picker`
|
||||
// (the `Picker` gets dropped only when a new `Picker` is created). Furthermore, the
|
||||
// process of reconstructing a `&str` back requires that we have access to the original
|
||||
// `String` anyways so we can index into it, as is the case when we construct the `Span`
|
||||
// when creating the `PickerColumn`s, so we know that we are returning the correct
|
||||
// substring from the original `file_contents`.
|
||||
// In fact, since we only store offsets, and accessing them from safe rust, there is
|
||||
// no risk of memory safety (like our &str not living long enough). The only real
|
||||
// bug would be moving out the original underlying `String` (which we obviously
|
||||
// don't do). This would lead to an out of bounds crash in the `PickerColumn` function
|
||||
// call, or a crash when we recreate back the &str if the new underlying `String`
|
||||
// makes it so that our byte offsets index into the middle of a Unicode grapheme cluster.
|
||||
// Last but not least, it could make it so that we do display the lines correctly,
|
||||
// but these are from a different underlying `String` than the original, which would be
|
||||
// different from the lines in the current buffer.
|
||||
let beg =
|
||||
unsafe { line.as_ptr().byte_offset_from(file_contents.as_ptr()) } as usize;
|
||||
let end = beg + line.len();
|
||||
let result = FileResult {
|
||||
path: current_document_path.clone(),
|
||||
line_num,
|
||||
file_contents_byte_start: beg,
|
||||
file_contents_byte_end: end,
|
||||
};
|
||||
Some(result)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let config = LocalSearchData {
|
||||
number_style: cx.editor.theme.get("constant.numeric.integer"),
|
||||
file_contents,
|
||||
};
|
||||
|
||||
let columns = [
|
||||
PickerColumn::new("line", |item: &FileResult, config: &LocalSearchData| {
|
||||
let line_num = (item.line_num + 1).to_string();
|
||||
// files can never contain more than 99_999_999 lines
|
||||
// thus using maximum line length to be 8 for this formatter is valid
|
||||
let max_line_num_length = 8;
|
||||
// whitespace padding to align results after the line number
|
||||
let padding_length = max_line_num_length - line_num.len();
|
||||
let padding = " ".repeat(padding_length);
|
||||
// create column value to be displayed in the picker
|
||||
Cell::from(Spans::from(vec![
|
||||
Span::styled(line_num, config.number_style),
|
||||
Span::raw(padding),
|
||||
]))
|
||||
}),
|
||||
PickerColumn::new("", |item: &FileResult, config: &LocalSearchData| {
|
||||
// extract line content to be displayed in the picker
|
||||
let slice = &config.file_contents.as_bytes()
|
||||
[item.file_contents_byte_start..item.file_contents_byte_end];
|
||||
let content = std::str::from_utf8(slice).unwrap();
|
||||
// create column value to be displayed in the picker
|
||||
Cell::from(Spans::from(vec![Span::raw(content)]))
|
||||
}),
|
||||
];
|
||||
|
||||
let reg = cx.register.unwrap_or('/');
|
||||
cx.editor.registers.last_search_register = reg;
|
||||
|
||||
let picker = Picker::new(
|
||||
columns,
|
||||
1, // contents
|
||||
[],
|
||||
config,
|
||||
move |cx, FileResult { path, line_num, .. }, action| {
|
||||
let doc = match cx.editor.open(path, action) {
|
||||
Ok(id) => doc_mut!(cx.editor, &id),
|
||||
Err(e) => {
|
||||
cx.editor
|
||||
.set_error(format!("Failed to open file '{}': {}", path.display(), e));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let line_num = *line_num;
|
||||
let view = view_mut!(cx.editor);
|
||||
let text = doc.text();
|
||||
if line_num >= text.len_lines() {
|
||||
cx.editor.set_error(
|
||||
"The line you jumped to does not exist anymore because the file has changed.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
let start = text.line_to_char(line_num);
|
||||
let end = text.line_to_char((line_num + 1).min(text.len_lines()));
|
||||
|
||||
doc.set_selection(view.id, Selection::single(start, end));
|
||||
if action.align_view(view, doc.id()) {
|
||||
align_view(doc, view, Align::Center);
|
||||
}
|
||||
},
|
||||
)
|
||||
.with_preview(|_editor, FileResult { path, line_num, .. }| {
|
||||
Some((path.as_path().into(), Some((*line_num, *line_num))))
|
||||
})
|
||||
.with_history_register(Some(reg));
|
||||
|
||||
let injector = picker.injector();
|
||||
let timeout = std::time::Instant::now() + std::time::Duration::from_millis(30);
|
||||
for file_result in file_results {
|
||||
if injector.push(file_result).is_err() {
|
||||
break;
|
||||
}
|
||||
if std::time::Instant::now() >= timeout {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
cx.push_layer(Box::new(overlaid(picker)));
|
||||
}
|
||||
|
||||
enum Extend {
|
||||
Above,
|
||||
Below,
|
||||
|
|
|
@ -282,7 +282,8 @@ pub fn default() -> HashMap<Mode, KeyTrie> {
|
|||
"P" => paste_clipboard_before,
|
||||
"R" => replace_selections_with_clipboard,
|
||||
"/" => global_search,
|
||||
"l" => local_search,
|
||||
"l" => local_search_grep,
|
||||
"L" => local_search_fuzzy,
|
||||
"k" => hover,
|
||||
"r" => rename_symbol,
|
||||
"h" => select_references_to_symbol_under_cursor,
|
||||
|
|
Loading…
Reference in New Issue