Vectrum
Sign In
382 lines · 14.5 KB · Rust
//! 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()));
    }
}