feat: add floating and docked searchable file picker

This commit is contained in:
Valdemar Lorenzen
2026-10-08 14:25:54 +02:00
parent 5a67bf64a3
commit c60e625555
12 changed files with 1820 additions and 34 deletions
+339
View File
@@ -0,0 +1,339 @@
use ignore::WalkBuilder;
use std::fs;
use std::io;
use std::path::PathBuf;
use unicode_segmentation::UnicodeSegmentation;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum FilePresentation {
Floating,
Docked,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum DockSide {
Left,
Right,
Top,
Bottom,
}
pub struct FileEntry {
/// The actual project-relative OS path, never reconstructed from the label.
pub path: PathBuf,
pub label: String,
search_label: String,
}
pub struct FilePicker {
pub root: PathBuf,
pub query: String,
pub selected: usize,
pub presentation: FilePresentation,
pub focused: bool,
pub error: Option<String>,
files: Vec<FileEntry>,
matches: Vec<usize>,
warning: Option<String>,
}
impl FilePicker {
pub fn scan(root: PathBuf, presentation: FilePresentation) -> io::Result<Self> {
if !fs::metadata(&root)?.is_dir() {
return Err(io::Error::new(
io::ErrorKind::NotADirectory,
"Project root is not a directory",
));
}
// Root failures are fatal; descendant failures leave a visibly incomplete list.
fs::read_dir(&root)?;
// ponytail: synchronous scan; move indexing off the input thread if large projects stall.
let mut walk = WalkBuilder::new(&root);
walk.require_git(false)
.parents(false)
.hidden(false)
.git_global(false)
.follow_links(false)
.filter_entry(|entry| {
entry.depth() == 0 || (entry.file_name() != ".git" && entry.file_name() != ".jj")
});
let mut files = Vec::new();
let mut warning = None;
for entry in walk.build() {
let entry = match entry {
Ok(entry) => entry,
Err(error) => {
if error.depth() == Some(0) {
return Err(io::Error::other(error));
}
warning.get_or_insert_with(|| format!("Incomplete scan: {error}"));
continue;
}
};
if let Some(error) = entry.error() {
warning.get_or_insert_with(|| format!("Incomplete scan: {error}"));
}
// Symlinks (including file symlinks) are intentionally excluded.
if !entry.file_type().is_some_and(|kind| kind.is_file()) {
continue;
}
let path = entry
.path()
.strip_prefix(&root)
.expect("walk stays within root")
.to_owned();
let label: String = path
.to_string_lossy()
.chars()
.map(|ch| if ch.is_control() { '�' } else { ch })
.collect();
let search_label = label.to_lowercase();
files.push(FileEntry {
path,
label,
search_label,
});
}
files.sort_by(|left, right| left.path.cmp(&right.path));
let matches = (0..files.len()).collect();
Ok(Self {
root,
query: String::new(),
selected: 0,
presentation,
focused: true,
error: warning.clone(),
files,
matches,
warning,
})
}
pub fn entries(&self) -> impl ExactSizeIterator<Item = &FileEntry> + '_ {
self.matches.iter().map(|&index| &self.files[index])
}
pub(crate) fn refilter(&mut self) {
let query = self.query.to_lowercase();
let tokens: Vec<_> = query.split_whitespace().collect();
self.matches.clear();
self.matches
.extend(self.files.iter().enumerate().filter_map(|(index, entry)| {
tokens
.iter()
.all(|token| entry.search_label.contains(token))
.then_some(index)
}));
self.selected = 0;
self.clear_error();
}
pub(crate) fn backspace(&mut self) {
if let Some((byte, _)) = self.query.grapheme_indices(true).next_back() {
self.query.truncate(byte);
self.refilter();
}
}
pub(crate) fn navigate(&mut self, forward: bool) {
let count = self.matches.len();
self.selected = if count == 0 {
0
} else if forward {
(self.selected + 1) % count
} else if self.selected == 0 {
count - 1
} else {
self.selected - 1
};
self.clear_error();
}
pub(crate) fn clear_error(&mut self) {
self.error = self.warning.clone();
}
pub(crate) fn refresh(&mut self) {
match Self::scan(self.root.clone(), self.presentation) {
Ok(mut replacement) => {
replacement.query = self.query.clone();
replacement.focused = self.focused;
replacement.refilter();
*self = replacement;
}
Err(error) => self.error = Some(format!("Refresh failed: {error}")),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU64, Ordering};
static TEMP_ID: AtomicU64 = AtomicU64::new(0);
struct Directory(PathBuf);
impl Directory {
fn new() -> Self {
let id = TEMP_ID.fetch_add(1, Ordering::Relaxed);
let path =
std::env::temp_dir().join(format!("vamp-files-test-{}-{id}", std::process::id()));
fs::create_dir(&path).unwrap();
Self(path)
}
fn put(&self, name: &str, text: &str) {
let path = self.0.join(name);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, text).unwrap();
}
fn scan(&self) -> FilePicker {
FilePicker::scan(self.0.clone(), FilePresentation::Floating).unwrap()
}
}
impl Drop for Directory {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn paths(picker: &FilePicker) -> Vec<PathBuf> {
picker.entries().map(|entry| entry.path.clone()).collect()
}
#[test]
fn nested_ignore_negation_hidden_files_and_vcs_metadata() {
let directory = Directory::new();
directory.put(".gitignore", "*.log\nignored/\n");
directory.put("nested/.gitignore", "!keep.log\nlocal.txt\n");
for name in [
"gone.log",
"nested/gone.log",
"nested/keep.log",
"nested/local.txt",
"nested/ok.txt",
"ignored/hidden.txt",
".hidden",
".git/objects/secret",
"nested/.git/secret",
".jj/secret",
"nested/.jj/secret",
] {
directory.put(name, "text");
}
let picker = directory.scan();
assert_eq!(
paths(&picker),
[
".gitignore",
".hidden",
"nested/.gitignore",
"nested/keep.log",
"nested/ok.txt"
]
.map(PathBuf::from)
);
assert!(picker.error.is_none());
}
#[test]
fn literal_unicode_tokens_grapheme_deletion_and_navigation() {
let directory = Directory::new();
for name in ["a space 界[e\u{301}].txt", "b.txt", "c.txt"] {
directory.put(name, "text");
}
let mut picker = directory.scan();
picker.navigate(false);
assert_eq!(picker.selected, 2);
picker.navigate(true);
assert_eq!(picker.selected, 0);
picker.query = "界 SPACE [E\u{301}]".into();
picker.refilter();
assert_eq!(paths(&picker), [PathBuf::from("a space 界[e\u{301}].txt")]);
picker.query = "e\u{301}👩‍💻".into();
picker.backspace();
assert_eq!(picker.query, "e\u{301}");
picker.backspace();
assert_eq!(picker.query, "");
picker.backspace();
assert_eq!(picker.entries().len(), 3);
picker.query = "no match".into();
picker.refilter();
for forward in [true, false] {
picker.navigate(forward);
assert_eq!(picker.selected, 0);
assert_eq!(picker.entries().len(), 0);
}
}
#[test]
fn empty_scan_refresh_and_failure_preserve_reachable_state() {
let directory = Directory::new();
let mut picker = directory.scan();
picker.navigate(false);
picker.backspace();
assert_eq!(picker.entries().len(), 0);
directory.put("new.txt", "text");
picker.query = "new".into();
picker.refresh();
assert_eq!(picker.query, "new");
assert_eq!(paths(&picker), [PathBuf::from("new.txt")]);
fs::remove_dir_all(&directory.0).unwrap();
picker.refresh();
assert_eq!(paths(&picker), [PathBuf::from("new.txt")]);
assert!(picker.error.as_ref().unwrap().contains("Refresh failed"));
assert!(FilePicker::scan(directory.0.clone(), FilePresentation::Docked).is_err());
}
#[test]
fn malformed_ignore_warning_survives_query_and_navigation() {
let directory = Directory::new();
directory.put(".gitignore", "[z-a]\n");
directory.put("ok.txt", "text");
let mut picker = directory.scan();
assert!(picker.error.as_ref().unwrap().contains("Incomplete scan"));
picker.query = "ok".into();
picker.refilter();
assert_eq!(paths(&picker), [PathBuf::from("ok.txt")]);
picker.navigate(true);
assert!(picker.error.as_ref().unwrap().contains("Incomplete scan"));
directory.put(".gitignore", "");
picker.refresh();
assert!(picker.error.is_none());
}
#[test]
fn only_project_ignore_rules_apply_and_non_directory_roots_fail() {
let directory = Directory::new();
directory.put(".gitignore", "*.txt\n");
directory.put("project/visible.txt", "text");
let root = directory.0.join("project");
let picker = FilePicker::scan(root, FilePresentation::Floating).unwrap();
assert_eq!(paths(&picker), [PathBuf::from("visible.txt")]);
assert!(
FilePicker::scan(
directory.0.join("project/visible.txt"),
FilePresentation::Docked
)
.is_err()
);
}
#[cfg(unix)]
#[test]
fn symlink_loops_and_file_links_are_not_followed_and_raw_paths_survive() {
use std::os::unix::fs::symlink;
let directory = Directory::new();
directory.put("real.txt", "text");
symlink(&directory.0, directory.0.join("loop")).unwrap();
symlink(directory.0.join("real.txt"), directory.0.join("alias.txt")).unwrap();
let raw = PathBuf::from("raw界\n.txt");
fs::write(directory.0.join(&raw), "text").unwrap();
let picker = directory.scan();
assert_eq!(picker.entries().len(), 2);
let entry = picker.entries().find(|entry| entry.path == raw).unwrap();
assert!(!entry.label.chars().any(char::is_control));
assert_eq!(
fs::read_to_string(directory.0.join(&entry.path)).unwrap(),
"text"
);
}
}