//! The folders the Add project palette browses: a path field filters and
//! completes the listing below it, like a shell. The listing is of this
//! device, or of another one, which sends it on request.
use std::path::{Component, Path, PathBuf};
use serde::{Deserialize, Serialize};
/// The most folders one listing shows.
const MAX_FOLDERS: usize = 2000;
/// The most entries read from one folder, so a folder full of files stays quick.
const MAX_SCANNED: usize = 20_000;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Folder {
pub name: String,
/// It has a `.git`, so it's likely a project.
pub git: bool,
}
/// A folder's subfolders, and whether the listing stopped short.
pub type Folders = Result<(Vec<Folder>, bool), String>;
enum Listing {
/// Asked another device for it.
Loading,
Ready { folders: Vec<Folder>, truncated: bool },
Failed(String),
}
/// The listing behind the path field.
pub struct FolderListing {
/// The folder part of the field it lists: `~/Work/`.
dir: Option<String>,
listing: Listing,
/// The partial name typed after the folder, which filters the listing.
filter: String,
matches: Vec<usize>,
}
impl Default for FolderListing {
fn default() -> Self {
Self { dir: None, listing: Listing::Loading, filter: String::new(), matches: Vec::new() }
}
}
impl FolderListing {
/// Follows the field. Returns the folder to list when it changed, for
/// the caller to read here or ask the other device for; it's
/// [`Loading`](Listing::Loading) until [`receive`](Self::receive)d.
pub fn set_text(&mut self, text: &str) -> Option<String> {
let (dir, filter) = split_field(text);
self.filter = filter;
let changed = self.dir.as_deref() != Some(dir.as_str());
if changed {
self.dir = Some(dir.clone());
self.listing = Listing::Loading;
}
self.refilter();
changed.then_some(dir)
}
/// A listing arrived; it's dropped if the field moved on since.
pub fn receive(&mut self, dir: &str, folders: Folders) {
if self.dir.as_deref() != Some(dir) {
return;
}
self.listing = match folders {
Ok((folders, truncated)) => Listing::Ready { folders, truncated },
Err(error) => Listing::Failed(error),
};
self.refilter();
}
fn refilter(&mut self) {
self.matches = match &self.listing {
Listing::Ready { folders, .. } => {
let names: Vec<&str> = folders.iter().map(|folder| folder.name.as_str()).collect();
filter_names(&names, &self.filter)
}
_ => Vec::new(),
};
}
fn folders(&self) -> &[Folder] {
match &self.listing {
Listing::Ready { folders, .. } => folders,
_ => &[],
}
}
/// The folders that match what's typed, best first.
pub fn matches(&self) -> impl Iterator<Item = &Folder> {
let folders = self.folders();
self.matches.iter().map(move |&ix| &folders[ix])
}
/// The field's text for going into one of the folders.
pub fn open(&self, name: &str) -> String {
format!("{}{name}/", self.dir.as_deref().unwrap_or_default())
}
/// Whether the folder the field names exists, as far as the listing
/// tells: the folder part does, and the rest is empty or names one of it.
pub fn names_folder(&self) -> bool {
matches!(self.listing, Listing::Ready { .. })
&& (self.filter.is_empty() || self.folders().iter().any(|folder| folder.name == self.filter))
}
/// Tab: the field completed to the one folder the partial name matches,
/// or as far as all its matches agree.
pub fn complete(&self) -> Option<String> {
let names: Vec<&str> = self.folders().iter().map(|folder| folder.name.as_str()).collect();
let completion = complete_name(&names, &self.filter)?;
Some(format!("{}{completion}", self.dir.as_deref().unwrap_or_default()))
}
/// A muted line in place of rows, when there's something to explain.
pub fn message(&self) -> Option<String> {
match &self.listing {
Listing::Loading => Some("Loading…".into()),
Listing::Failed(error) => Some(error.clone()),
Listing::Ready { truncated: true, .. } => {
Some(format!("Showing the first {MAX_FOLDERS} folders. Type to narrow them down."))
}
Listing::Ready { folders, .. } if folders.is_empty() => Some("No folders here.".into()),
Listing::Ready { .. } if self.matches.is_empty() => Some("No folders match.".into()),
Listing::Ready { .. } => None,
}
}
}
/// Lists the folder `dir` names (as typed: `~/Work/`) on this device.
pub fn list(dir: &str, home: &Path) -> Folders {
let path = expand(dir, home);
if !path.is_dir() {
return Err("No such folder.".into());
}
read_folders(&path).map_err(|err| match err.kind() {
std::io::ErrorKind::PermissionDenied => "Can't read this folder: permission denied.".into(),
_ => format!("Can't read this folder: {err}"),
})
}
/// Where the Add project browser opens: the saved "Add project starts in"
/// folder (`~` and `~/…` expanded), or the home folder when that's empty or
/// isn't a folder.
pub fn start_dir(setting: Option<&str>) -> PathBuf {
let home = home_dir();
match setting.map(str::trim).filter(|setting| !setting.is_empty()) {
Some(setting) => Some(expand(setting, &home)).filter(|dir| dir.is_dir()).unwrap_or(home),
None => home,
}
}
pub fn home_dir() -> PathBuf {
std::env::var_os("HOME").map(PathBuf::from).unwrap_or_else(|| PathBuf::from("/"))
}
/// Turns what's typed into an absolute path: `~` is `home`, a relative path
/// is taken from `home`, and `.` and `..` are resolved.
pub fn expand(text: &str, home: &Path) -> PathBuf {
let path = if text == "~" {
home.to_path_buf()
} else if let Some(rest) = text.strip_prefix("~/") {
home.join(rest)
} else {
home.join(text)
};
let mut normal = PathBuf::new();
for component in path.components() {
match component {
Component::CurDir => {}
Component::ParentDir => {
normal.pop();
}
component => normal.push(component),
}
}
normal
}
/// A path as the field shows it, with `~` for the home folder.
pub fn tilde(path: &Path, home: &Path) -> String {
match path.strip_prefix(home) {
Ok(rest) if home.parent().is_some() && rest.as_os_str().is_empty() => "~".into(),
Ok(rest) if home.parent().is_some() => format!("~/{}", rest.display()),
_ => path.display().to_string(),
}
}
/// The field's text for listing `dir`: its path with a trailing slash.
pub fn field_text(dir: &Path, home: &Path) -> String {
let text = tilde(dir, home);
if text.ends_with('/') { text } else { format!("{text}/") }
}
/// Splits the field into the folder to list and the partial name after it:
/// `~/Work/vec` is `~/Work/` filtered by `vec`. A bare `~` is the home folder.
fn split_field(text: &str) -> (String, String) {
match text.rfind('/') {
_ if text == "~" => ("~/".into(), String::new()),
Some(ix) => (text[..=ix].to_string(), text[ix + 1..].to_string()),
None => (String::new(), text.to_string()),
}
}
/// The names matching `filter`, case-insensitively: those starting with it
/// first, then those containing it. Dot-folders only show when the filter
/// starts with a dot.
fn filter_names(names: &[&str], filter: &str) -> Vec<usize> {
let needle = filter.to_lowercase();
let (mut starts, mut contains) = (Vec::new(), Vec::new());
for (ix, name) in names.iter().enumerate() {
if name.starts_with('.') && !filter.starts_with('.') {
continue;
}
let name = name.to_lowercase();
if name.starts_with(&needle) {
starts.push(ix);
} else if name.contains(&needle) {
contains.push(ix);
}
}
starts.extend(contains);
starts
}
/// What Tab turns `filter` into: the one name starting with it plus a slash,
/// or the longest prefix the names starting with it share. `None` when
/// that adds nothing.
fn complete_name(names: &[&str], filter: &str) -> Option<String> {
let needle = filter.to_lowercase();
let matches: Vec<&str> = names
.iter()
.copied()
.filter(|name| !name.starts_with('.') || filter.starts_with('.'))
.filter(|name| name.to_lowercase().starts_with(&needle))
.collect();
match matches.as_slice() {
[] => None,
[only] => Some(format!("{only}/")),
[first, rest @ ..] => {
let len = rest.iter().fold(first.len(), |len, name| common_prefix_len(&first[..len], name));
let prefix = &first[..len];
(prefix.chars().count() > filter.chars().count()).then(|| prefix.to_string())
}
}
}
/// How many bytes of `a` start `b` too, ignoring case.
fn common_prefix_len(a: &str, b: &str) -> usize {
let mut len = 0;
for ((ix, x), y) in a.char_indices().zip(b.chars()) {
if !x.to_lowercase().eq(y.to_lowercase()) {
return ix;
}
len = ix + x.len_utf8();
}
len
}
/// The subfolders of `dir`, sorted case-insensitively, and whether the
/// listing stopped short.
fn read_folders(dir: &Path) -> std::io::Result<(Vec<Folder>, bool)> {
let mut folders = Vec::new();
let mut truncated = false;
for (scanned, entry) in std::fs::read_dir(dir)?.enumerate() {
if scanned >= MAX_SCANNED || folders.len() >= MAX_FOLDERS {
truncated = true;
break;
}
let Ok(entry) = entry else { continue };
let path = entry.path();
// Follow symlinks, so a linked folder lists like any other.
let is_dir = match entry.file_type() {
Ok(kind) if kind.is_symlink() => path.is_dir(),
Ok(kind) => kind.is_dir(),
Err(_) => false,
};
if is_dir {
folders.push(Folder {
name: entry.file_name().to_string_lossy().into_owned(),
git: path.join(".git").exists(),
});
}
}
folders.sort_by_cached_key(|folder| folder.name.to_lowercase());
Ok((folders, truncated))
}
#[cfg(test)]
mod tests {
// Not a glob: GPUI exports its own `test` attribute.
use super::{FolderListing, complete_name, expand, field_text, filter_names, home_dir, split_field, start_dir, tilde};
use std::path::{Path, PathBuf};
const HOME: &str = "/home/ada";
#[::core::prelude::v1::test]
fn expands_paths() {
let home = Path::new(HOME);
assert_eq!(expand("~", home), PathBuf::from(HOME));
assert_eq!(expand("~/", home), PathBuf::from(HOME));
assert_eq!(expand("~/Work/rust/", home), PathBuf::from("/home/ada/Work/rust"));
assert_eq!(expand("/etc", home), PathBuf::from("/etc"));
assert_eq!(expand("Work", home), PathBuf::from("/home/ada/Work"));
assert_eq!(expand("~/Work/../src/./x", home), PathBuf::from("/home/ada/src/x"));
assert_eq!(expand("/..", home), PathBuf::from("/"));
}
#[::core::prelude::v1::test]
fn start_dir_falls_back_to_home() {
assert_eq!(start_dir(None), home_dir());
assert_eq!(start_dir(Some(" ")), home_dir());
assert_eq!(start_dir(Some("~")), home_dir());
assert_eq!(start_dir(Some("/no/such/folder/here")), home_dir());
assert_eq!(start_dir(Some("/")), PathBuf::from("/"));
}
#[::core::prelude::v1::test]
fn shows_home_as_tilde() {
let home = Path::new(HOME);
assert_eq!(tilde(Path::new(HOME), home), "~");
assert_eq!(tilde(Path::new("/home/ada/Work"), home), "~/Work");
assert_eq!(tilde(Path::new("/home/adam"), home), "/home/adam");
assert_eq!(tilde(Path::new("/etc"), Path::new("/")), "/etc");
assert_eq!(field_text(Path::new(HOME), home), "~/");
assert_eq!(field_text(Path::new("/"), home), "/");
assert_eq!(field_text(Path::new("/etc"), home), "/etc/");
}
#[::core::prelude::v1::test]
fn splits_the_field() {
assert_eq!(split_field("~/Work/vec"), ("~/Work/".into(), "vec".into()));
assert_eq!(split_field("~/Work/"), ("~/Work/".into(), String::new()));
assert_eq!(split_field("~"), ("~/".into(), String::new()));
assert_eq!(split_field(""), (String::new(), String::new()));
assert_eq!(split_field("/e"), ("/".into(), "e".into()));
}
#[::core::prelude::v1::test]
fn follows_the_field() {
let folder = |name: &str| super::Folder { name: name.into(), git: false };
let mut listing = FolderListing::default();
assert_eq!(listing.set_text("~/Work/ve"), Some("~/Work/".into()));
assert_eq!(listing.message().as_deref(), Some("Loading…"));
listing.receive("~/Work/", Ok((vec![folder("rust"), folder("vectrum")], false)));
assert_eq!(listing.matches().map(|folder| folder.name.as_str()).collect::<Vec<_>>(), ["vectrum"]);
assert_eq!(listing.complete().as_deref(), Some("~/Work/vectrum/"));
assert!(!listing.names_folder());
// The same folder isn't asked for twice.
assert_eq!(listing.set_text("~/Work/rust"), None);
assert!(listing.names_folder());
assert_eq!(listing.open("rust"), "~/Work/rust/");
// A late answer for another folder is dropped.
listing.set_text("~/");
listing.receive("~/Work/", Ok((Vec::new(), false)));
assert_eq!(listing.message().as_deref(), Some("Loading…"));
}
#[::core::prelude::v1::test]
fn filters_names() {
let names = [".config", "Desktop", "docs", "my-docs", "Work"];
assert_eq!(filter_names(&names, ""), vec![1, 2, 3, 4]);
assert_eq!(filter_names(&names, "D"), vec![1, 2, 3]);
assert_eq!(filter_names(&names, "docs"), vec![2, 3]);
assert_eq!(filter_names(&names, "."), vec![0]);
assert_eq!(filter_names(&names, "zzz"), Vec::<usize>::new());
}
#[::core::prelude::v1::test]
fn completes_names() {
let names = [".cargo", "Workbench", "workshop", "Writing"];
assert_eq!(complete_name(&names, "wr"), Some("Writing/".into()));
assert_eq!(complete_name(&names, "wo"), Some("Work".into()));
assert_eq!(complete_name(&names, "work"), None);
assert_eq!(complete_name(&names, "x"), None);
assert_eq!(complete_name(&names, "."), Some(".cargo/".into()));
assert_eq!(complete_name(&names, ""), Some("W".into()));
}
}