//! 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, bool), String>; enum Listing { /// Asked another device for it. Loading, Ready { folders: Vec, truncated: bool }, Failed(String), } /// The listing behind the path field. pub struct FolderListing { /// The folder part of the field it lists: `~/Work/`. dir: Option, listing: Listing, /// The partial name typed after the folder, which filters the listing. filter: String, matches: Vec, } 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 { 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 { 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 { 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 { 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 { 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 { 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, 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::>(), ["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::::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())); } }