feat: reuse fuzzy search in the file picker
This commit is contained in:
@@ -12,7 +12,7 @@
|
|||||||
- [x] diff view: automatically detect changes and update the view when relevant bookmarks move change ids (regards bookmark-based diff view; i.e., user selected at least one bookmark in the before/after picker)
|
- [x] diff view: automatically detect changes and update the view when relevant bookmarks move change ids (regards bookmark-based diff view; i.e., user selected at least one bookmark in the before/after picker)
|
||||||
- [x] action to scroll viewport without having to move the cursor (like vim's `ctrl+e|u`).
|
- [x] action to scroll viewport without having to move the cursor (like vim's `ctrl+e|u`).
|
||||||
- [x] scroll viewport action (`ctrl+u|d`) scroll distance should be configurable.
|
- [x] scroll viewport action (`ctrl+u|d`) scroll distance should be configurable.
|
||||||
- [ ] file picker file search should also be fuzzy (we should be able to use the same fuzzy implemented for diff view search, i believe)
|
- [x] file picker file search should also be fuzzy (we should be able to use the same fuzzy implemented for diff view search, i believe)
|
||||||
- [ ] diff view: labels are currently attached also when scrolling the view port horizontally (such that e.g. a function name is not fully visible in the viewport), which is good, but it currently attaches at that line, thus pushing down the function by one line, which causes a one-line jump when scrolling horizontally.
|
- [ ] diff view: labels are currently attached also when scrolling the view port horizontally (such that e.g. a function name is not fully visible in the viewport), which is good, but it currently attaches at that line, thus pushing down the function by one line, which causes a one-line jump when scrolling horizontally.
|
||||||
- we should in this case attach the label above the function instead of on the same line that it's defined such that it's not pushed down.
|
- we should in this case attach the label above the function instead of on the same line that it's defined such that it's not pushed down.
|
||||||
- [ ] 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: github integration (code review) - add comments on a pr, add review comments at specific lines of files, request changes, approve pr, etc.
|
||||||
|
|||||||
@@ -344,6 +344,8 @@ Both presentations default to Tree; [configuration preferences](configuration.md
|
|||||||
|
|
||||||
Floating Tree opens in explorer **INSERT** mode; a new docked Tree opens in **NORMAL**. Press `i` in NORMAL to resume editing the existing filter; the `i` itself is not added to the query. Typing and Backspace filter full relative paths by case-insensitive whitespace tokens. These are explorer-local modes, not changes to the file buffer's mode. Matching nodes appear with ancestor context, and query changes initially select a direct match. Escape in INSERT changes only to NORMAL: the filter, visible results, highlighted item, and explorer focus remain. NORMAL displays the active filter and navigates that filtered tree. Escape from NORMAL returns focus to the buffer (or closes floating search). List always accepts query typing immediately.
|
Floating Tree opens in explorer **INSERT** mode; a new docked Tree opens in **NORMAL**. Press `i` in NORMAL to resume editing the existing filter; the `i` itself is not added to the query. Typing and Backspace filter full relative paths by case-insensitive whitespace tokens. These are explorer-local modes, not changes to the file buffer's mode. Matching nodes appear with ancestor context, and query changes initially select a direct match. Escape in INSERT changes only to NORMAL: the filter, visible results, highlighted item, and explorer focus remain. NORMAL displays the active filter and navigates that filtered tree. Escape from NORMAL returns focus to the buffer (or closes floating search). List always accepts query typing immediately.
|
||||||
|
|
||||||
|
Search reuses the diff revision selector's fuzzy matcher in both Tree and List, including changed-file explorers. Each whitespace-separated token must match an ordered character subsequence of the full relative path; tokens can appear in any order. For example, `sr/fp RS` finds `src/fuzzy_parser.rs`. Catalog order and folder context stay unchanged rather than ranking the results.
|
||||||
|
|
||||||
| File-view input | Behavior |
|
| File-view input | Behavior |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Type / Backspace | Type query text in List/explorer INSERT / remove one whole grapheme; explorer NORMAL uses hjkl for navigation |
|
| Type / Backspace | Type query text in List/explorer INSERT / remove one whole grapheme; explorer NORMAL uses hjkl for navigation |
|
||||||
|
|||||||
+16
-72
@@ -1,5 +1,5 @@
|
|||||||
use crate::editor::{BackspaceKind, Input};
|
use crate::editor::{BackspaceKind, Input};
|
||||||
use crate::files::{FilePicker, FilePresentation, FileView};
|
use crate::files::{FilePicker, FilePresentation, FileView, fuzzy_match};
|
||||||
use std::{
|
use std::{
|
||||||
collections::{BTreeMap, BTreeSet},
|
collections::{BTreeMap, BTreeSet},
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
@@ -963,18 +963,16 @@ impl DiffState {
|
|||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.filter_map(|(field_index, field)| {
|
.filter_map(|(field_index, field)| {
|
||||||
revision_match(field, token).map(
|
fuzzy_match(field, token).map(|[kind, boundaries, gaps, start, len]| {
|
||||||
|[kind, boundaries, gaps, start, len]| {
|
[
|
||||||
[
|
kind,
|
||||||
kind,
|
usize::from(field_index == 2),
|
||||||
usize::from(field_index == 2),
|
boundaries,
|
||||||
boundaries,
|
gaps,
|
||||||
gaps,
|
start,
|
||||||
start,
|
len,
|
||||||
len,
|
]
|
||||||
]
|
})
|
||||||
},
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
.min()?;
|
.min()?;
|
||||||
// A scattered token never outranks an all-contiguous match.
|
// A scattered token never outranks an all-contiguous match.
|
||||||
@@ -2168,60 +2166,6 @@ impl DiffState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Match class, missed word/reference boundaries, gaps, start, field length.
|
|
||||||
fn revision_match(field: &str, token: &str) -> Option<[usize; 5]> {
|
|
||||||
if field == token {
|
|
||||||
return Some([0, 0, 0, 0, field.len()]);
|
|
||||||
}
|
|
||||||
if field.starts_with(token) {
|
|
||||||
return Some([1, 0, 0, 0, field.len()]);
|
|
||||||
}
|
|
||||||
let contiguous = field
|
|
||||||
.match_indices(token)
|
|
||||||
.map(|(start, _)| {
|
|
||||||
let boundary = field[..start]
|
|
||||||
.chars()
|
|
||||||
.next_back()
|
|
||||||
.is_none_or(|ch| !ch.is_alphanumeric());
|
|
||||||
[2, usize::from(!boundary), 0, start, field.len()]
|
|
||||||
})
|
|
||||||
.min();
|
|
||||||
if contiguous.is_some() {
|
|
||||||
return contiguous;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ponytail: greedy subsequences can miss tighter later alignments; use dynamic
|
|
||||||
// programming if that ranking precision becomes necessary.
|
|
||||||
let mut wanted = token.chars();
|
|
||||||
let mut next = wanted.next()?;
|
|
||||||
let mut start = 0;
|
|
||||||
let mut matched = 0;
|
|
||||||
let mut boundaries = 0;
|
|
||||||
let mut previous_is_word = false;
|
|
||||||
for (position, ch) in field.chars().enumerate() {
|
|
||||||
if ch == next {
|
|
||||||
if matched == 0 {
|
|
||||||
start = position;
|
|
||||||
}
|
|
||||||
matched += 1;
|
|
||||||
boundaries += usize::from(previous_is_word);
|
|
||||||
if let Some(ch) = wanted.next() {
|
|
||||||
next = ch;
|
|
||||||
} else {
|
|
||||||
return Some([
|
|
||||||
3,
|
|
||||||
boundaries,
|
|
||||||
position - start + 1 - matched,
|
|
||||||
start,
|
|
||||||
field.len(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
previous_is_word = ch.is_alphanumeric();
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
fn append_rows(
|
fn append_rows(
|
||||||
rows: &mut Vec<AlignedRow>,
|
rows: &mut Vec<AlignedRow>,
|
||||||
before: usize,
|
before: usize,
|
||||||
@@ -3468,11 +3412,11 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn revision_matches_rank_later_contiguous_boundaries_and_tight_subsequences() {
|
fn revision_matches_rank_later_contiguous_boundaries_and_tight_subsequences() {
|
||||||
assert_eq!(revision_match("r---f---s/topic/rfs", "rfs").unwrap()[0], 2);
|
assert_eq!(fuzzy_match("r---f---s/topic/rfs", "rfs").unwrap()[0], 2);
|
||||||
assert!(revision_match("topic/rfs", "rfs") < revision_match("topicxrfs", "rfs"));
|
assert!(fuzzy_match("topic/rfs", "rfs") < fuzzy_match("topicxrfs", "rfs"));
|
||||||
assert!(revision_match("r-f-s", "rfs") < revision_match("r---f---s", "rfs"));
|
assert!(fuzzy_match("r-f-s", "rfs") < fuzzy_match("r---f---s", "rfs"));
|
||||||
assert!(revision_match("über-parser", "üpr").is_some());
|
assert!(fuzzy_match("über-parser", "üpr").is_some());
|
||||||
assert!(revision_match("parser", "srp").is_none());
|
assert!(fuzzy_match("parser", "srp").is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+109
-1
@@ -105,6 +105,60 @@ pub struct FilePicker {
|
|||||||
has_virtual_entries: bool,
|
has_virtual_entries: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Lowercased field/token. Score: match class, missed word boundaries, gaps, start, length.
|
||||||
|
pub(crate) fn fuzzy_match(field: &str, token: &str) -> Option<[usize; 5]> {
|
||||||
|
if field == token {
|
||||||
|
return Some([0, 0, 0, 0, field.len()]);
|
||||||
|
}
|
||||||
|
if field.starts_with(token) {
|
||||||
|
return Some([1, 0, 0, 0, field.len()]);
|
||||||
|
}
|
||||||
|
let contiguous = field
|
||||||
|
.match_indices(token)
|
||||||
|
.map(|(start, _)| {
|
||||||
|
let boundary = field[..start]
|
||||||
|
.chars()
|
||||||
|
.next_back()
|
||||||
|
.is_none_or(|ch| !ch.is_alphanumeric());
|
||||||
|
[2, usize::from(!boundary), 0, start, field.len()]
|
||||||
|
})
|
||||||
|
.min();
|
||||||
|
if contiguous.is_some() {
|
||||||
|
return contiguous;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ponytail: greedy subsequences can miss tighter later alignments; use dynamic
|
||||||
|
// programming if that ranking precision becomes necessary.
|
||||||
|
let mut wanted = token.chars();
|
||||||
|
let mut next = wanted.next()?;
|
||||||
|
let mut start = 0;
|
||||||
|
let mut matched = 0;
|
||||||
|
let mut boundaries = 0;
|
||||||
|
let mut previous_is_word = false;
|
||||||
|
for (position, ch) in field.chars().enumerate() {
|
||||||
|
if ch == next {
|
||||||
|
if matched == 0 {
|
||||||
|
start = position;
|
||||||
|
}
|
||||||
|
matched += 1;
|
||||||
|
boundaries += usize::from(previous_is_word);
|
||||||
|
if let Some(ch) = wanted.next() {
|
||||||
|
next = ch;
|
||||||
|
} else {
|
||||||
|
return Some([
|
||||||
|
3,
|
||||||
|
boundaries,
|
||||||
|
position - start + 1 - matched,
|
||||||
|
start,
|
||||||
|
field.len(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
previous_is_word = ch.is_alphanumeric();
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
fn label(path: &Path) -> String {
|
fn label(path: &Path) -> String {
|
||||||
path.to_string_lossy()
|
path.to_string_lossy()
|
||||||
.chars()
|
.chars()
|
||||||
@@ -566,7 +620,11 @@ impl FilePicker {
|
|||||||
self.viewport_revision = NEXT_REVISION.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
self.viewport_revision = NEXT_REVISION.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||||
let query = self.query.to_lowercase();
|
let query = self.query.to_lowercase();
|
||||||
let tokens: Vec<_> = query.split_whitespace().collect();
|
let tokens: Vec<_> = query.split_whitespace().collect();
|
||||||
let matches = |node: &Node| tokens.iter().all(|token| node.search_label.contains(token));
|
let matches = |node: &Node| {
|
||||||
|
tokens
|
||||||
|
.iter()
|
||||||
|
.all(|token| fuzzy_match(&node.search_label, token).is_some())
|
||||||
|
};
|
||||||
self.rows.clear();
|
self.rows.clear();
|
||||||
if self.view == FileView::List {
|
if self.view == FileView::List {
|
||||||
self.rows
|
self.rows
|
||||||
@@ -1096,6 +1154,56 @@ mod tests {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fuzzy_path_tokens_preserve_tree_context_selection_and_open_paths() {
|
||||||
|
let directory = Directory::new();
|
||||||
|
let names = [
|
||||||
|
"src/fuzzy_parser.rs",
|
||||||
|
"src/über_parser.rs",
|
||||||
|
"src/other.rs",
|
||||||
|
"tests/fuzzy_parser.rs",
|
||||||
|
];
|
||||||
|
for name in names {
|
||||||
|
directory.put(name, "");
|
||||||
|
}
|
||||||
|
for mut picker in [
|
||||||
|
directory.scan(),
|
||||||
|
FilePicker::from_paths(
|
||||||
|
directory.0.clone(),
|
||||||
|
names.map(PathBuf::from).into_iter().enumerate(),
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
for view in [FileView::Tree, FileView::List] {
|
||||||
|
picker.set_view(view);
|
||||||
|
for (query, expected) in [
|
||||||
|
("sr/fp RS", "src/fuzzy_parser.rs"),
|
||||||
|
("ÜPR SRC", "src/über_parser.rs"),
|
||||||
|
] {
|
||||||
|
picker.query = query.into();
|
||||||
|
picker.refilter();
|
||||||
|
assert_eq!(
|
||||||
|
picker
|
||||||
|
.entries()
|
||||||
|
.filter(|row| !row.is_dir)
|
||||||
|
.map(|row| row.path)
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
[Path::new(expected)]
|
||||||
|
);
|
||||||
|
assert_eq!(selected_path(&picker), Path::new(expected));
|
||||||
|
assert_eq!(picker.accept(), Some(directory.0.join(expected)));
|
||||||
|
if view == FileView::Tree {
|
||||||
|
assert_eq!(paths(&picker), [Path::new("src"), Path::new(expected)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
picker.query = "rs/ü".into();
|
||||||
|
picker.refilter();
|
||||||
|
assert!(picker.entries().next().is_none());
|
||||||
|
picker.clear_filter();
|
||||||
|
assert_eq!(selected_path(&picker), Path::new("src/über_parser.rs"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn scanned_files_and_directories_have_no_catalog_index() {
|
fn scanned_files_and_directories_have_no_catalog_index() {
|
||||||
let directory = Directory::new();
|
let directory = Directory::new();
|
||||||
|
|||||||
Reference in New Issue
Block a user