feat: add floating and docked searchable file picker
This commit is contained in:
+339
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user