Vectrum
Sign In
623 lines · 23.6 KB · Rust
//! The Add project browser: an in-app folder picker, since the system dialog
//! can't be told which folder to start in. A path field on top filters and
//! completes the listing below it, like a shell.

use std::path::{Component, Path, PathBuf};

use gpui_kit::component::{
    Icon, IconName, h_flex,
    input::{Enter, Escape, IndentInline, Input, InputEvent, InputState, MoveDown, MoveUp},
    v_flex,
};
use gpui_kit::prelude::FluentBuilder as _;
use gpui_kit::*;

use crate::ui::metal::{self, MetalButton, MetalStyled as _};

/// 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;

pub enum FolderBrowserEvent {
    Picked(PathBuf),
    Cancelled,
    /// The user would rather use the system's folder dialog.
    SystemDialog,
}

struct Folder {
    name: String,
    /// It has a `.git`, so it's likely a project.
    git: bool,
}

/// One row of the list: the parent folder, or a folder of the listing.
#[derive(Clone, Copy, PartialEq, Eq)]
enum Row {
    Up,
    Folder(usize),
}

pub struct FolderBrowser {
    input: Entity<InputState>,
    home: PathBuf,
    /// The folder listed.
    dir: PathBuf,
    folders: Vec<Folder>,
    /// Why `dir` couldn't be read.
    error: Option<String>,
    /// The listing stopped at `MAX_FOLDERS`.
    truncated: bool,
    /// The field names a folder that doesn't exist.
    missing: bool,
    /// The partial name typed after the folder, which filters the listing.
    filter: String,
    rows: Vec<Row>,
    highlight: usize,
    scroll: ScrollHandle,
    _subscription: Subscription,
}

impl EventEmitter<FolderBrowserEvent> for FolderBrowser {}

impl Focusable for FolderBrowser {
    fn focus_handle(&self, cx: &App) -> FocusHandle {
        self.input.read(cx).focus_handle(cx)
    }
}

impl FolderBrowser {
    /// Opens on `start`, or on the home folder if `start` isn't a folder.
    pub fn new(start: PathBuf, window: &mut Window, cx: &mut Context<Self>) -> Self {
        let home = home_dir();
        let start = if start.is_dir() { start } else { home.clone() };
        let input = cx.new(|cx| {
            let mut input = InputState::new(window, cx).placeholder("~/");
            input.set_value(field_text(&start, &home), window, cx);
            input
        });
        let subscription = cx.subscribe(&input, |this, _, event: &InputEvent, cx| {
            if matches!(event, InputEvent::Change) {
                this.sync(cx);
            }
        });
        let mut browser = Self {
            input,
            home,
            dir: PathBuf::new(),
            folders: Vec::new(),
            error: None,
            truncated: false,
            missing: false,
            filter: String::new(),
            rows: Vec::new(),
            highlight: 0,
            scroll: ScrollHandle::new(),
            _subscription: subscription,
        };
        browser.sync(cx);
        browser
    }

    /// Lists the folder the field points at, filtered by the partial name
    /// after its last slash.
    fn sync(&mut self, cx: &mut Context<Self>) {
        let text = self.input.read(cx).value();
        let (dir, filter) = split_query(&text, &self.home);
        self.missing = !dir.is_dir();
        if !self.missing && dir != self.dir {
            match read_folders(&dir) {
                Ok((folders, truncated)) => {
                    self.folders = folders;
                    self.truncated = truncated;
                    self.error = None;
                }
                Err(err) => {
                    self.folders.clear();
                    self.truncated = false;
                    self.error = Some(match err.kind() {
                        std::io::ErrorKind::PermissionDenied => "Can't read this folder: permission denied.".into(),
                        _ => format!("Can't read this folder: {err}"),
                    });
                }
            }
            self.dir = dir;
        }
        self.filter = filter;

        let names: Vec<&str> = self.folders.iter().map(|folder| folder.name.as_str()).collect();
        let up = !self.missing && self.filter.is_empty() && self.dir.parent().is_some();
        self.rows = if self.missing {
            Vec::new()
        } else {
            up.then_some(Row::Up)
                .into_iter()
                .chain(filter_names(&names, &self.filter).into_iter().map(Row::Folder))
                .collect()
        };
        // Start on the first folder rather than "..".
        let first = self.rows.iter().position(|row| matches!(row, Row::Folder(_))).unwrap_or(0);
        self.set_highlight(first, cx);
    }

    fn set_highlight(&mut self, ix: usize, cx: &mut Context<Self>) {
        self.highlight = ix.min(self.rows.len().saturating_sub(1));
        self.scroll.scroll_to_item(self.highlight);
        cx.notify();
    }

    fn move_highlight(&mut self, delta: isize, cx: &mut Context<Self>) {
        let ix = self.highlight.saturating_add_signed(delta);
        self.set_highlight(ix, cx);
    }

    /// Lists `dir`, with the field pointing at it.
    fn navigate(&mut self, dir: &Path, window: &mut Window, cx: &mut Context<Self>) {
        let text = field_text(dir, &self.home);
        self.input.update(cx, |input, cx| {
            input.set_value(text, window, cx);
            input.focus(window, cx);
        });
        self.sync(cx);
    }

    /// Opens a row: up a level (keeping the folder we came from highlighted),
    /// or into the folder.
    fn open(&mut self, row: Row, window: &mut Window, cx: &mut Context<Self>) {
        match row {
            Row::Up => {
                let Some(parent) = self.dir.parent().map(Path::to_path_buf) else { return };
                let from = self.dir.file_name().map(|name| name.to_string_lossy().into_owned());
                self.navigate(&parent, window, cx);
                let came_from = self.rows.iter().position(|row| match row {
                    Row::Folder(ix) => Some(&self.folders[*ix].name) == from.as_ref(),
                    Row::Up => false,
                });
                if let Some(ix) = came_from {
                    self.set_highlight(ix, cx);
                }
            }
            Row::Folder(ix) => {
                let dir = self.dir.join(&self.folders[ix].name);
                self.navigate(&dir, window, cx);
            }
        }
    }

    /// Tab: completes the partial name to the one folder it matches, or as
    /// far as all its matches agree.
    fn complete(&mut self, window: &mut Window, cx: &mut Context<Self>) {
        let names: Vec<&str> = self.folders.iter().map(|folder| folder.name.as_str()).collect();
        let Some(completion) = complete_name(&names, &self.filter) else { return };
        let text = self.input.read(cx).value();
        let head = text.rfind('/').map_or("", |ix| &text[..=ix]);
        let text = format!("{head}{completion}");
        self.input.update(cx, |input, cx| input.set_value(text, window, cx));
        self.sync(cx);
    }

    /// The folder the field names, if it exists. "Add project" adds this one.
    fn target(&self, cx: &App) -> Option<PathBuf> {
        let path = expand(&self.input.read(cx).value(), &self.home);
        path.is_dir().then_some(path)
    }

    fn pick(&mut self, path: PathBuf, cx: &mut Context<Self>) {
        cx.emit(FolderBrowserEvent::Picked(path));
    }

    fn render_row(&self, ix: usize, row: Row, cx: &mut Context<Self>) -> AnyElement {
        let p = metal::palette(cx);
        let highlighted = ix == self.highlight;
        let group = SharedString::from(format!("folder-row-{ix}"));
        let (icon, name, git) = match row {
            Row::Up => (IconName::ArrowUp, "..".to_string(), false),
            Row::Folder(folder) => {
                let folder = &self.folders[folder];
                (IconName::Folder, folder.name.clone(), folder.git)
            }
        };
        h_flex()
            .id(("folder-row", ix))
            .group(group.clone())
            .flex_none()
            .h(px(32.))
            .px(px(10.))
            .gap(px(10.))
            .rounded(px(10.))
            .cursor_pointer()
            .hover(|style| style.bg(p.menu_row_hover))
            .when(highlighted, |this| this.bg(p.menu_row_hover))
            .on_click(cx.listener(move |this, _, window, cx| this.open(row, window, cx)))
            .child(Icon::new(icon).size(px(15.)).text_color(p.icon))
            .child(
                div()
                    .flex_1()
                    .min_w_0()
                    .truncate()
                    .text_size(px(13.5))
                    .font_weight(FontWeight::MEDIUM)
                    .text_color(p.ink)
                    .child(name.clone()),
            )
            .when(git, |this| this.child(metal::tag("git", p)))
            .when(row != Row::Up, |this| {
                let path = self.dir.join(&name);
                this.child(
                    div()
                        .when(!highlighted, |this| this.invisible().group_hover(group, |this| this.visible()))
                        .child(
                            MetalButton::new(("folder-add", ix))
                                .mini()
                                .label("Add")
                                .tooltip("Add this folder as a project")
                                .on_click(cx.listener(move |this, _, _, cx| {
                                    cx.stop_propagation();
                                    this.pick(path.clone(), cx);
                                })),
                        ),
                )
            })
            .into_any_element()
    }

    /// A muted line under the rows, when there's something to explain.
    fn message(&self) -> Option<String> {
        if self.missing {
            Some("No such folder.".into())
        } else if let Some(error) = &self.error {
            Some(error.clone())
        } else if self.truncated {
            Some(format!("Showing the first {MAX_FOLDERS} folders. Type to narrow them down."))
        } else if self.folders.is_empty() {
            Some("No folders here.".into())
        } else if !self.rows.iter().any(|row| matches!(row, Row::Folder(_))) {
            Some("No folders match.".into())
        } else {
            None
        }
    }
}

impl Render for FolderBrowser {
    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
        let p = metal::palette(cx);
        let target = self.target(cx);
        let footer_path = match &target {
            Some(path) => tilde(path, &self.home),
            None => "Pick a folder".into(),
        };
        let rows: Vec<AnyElement> =
            self.rows.clone().into_iter().enumerate().map(|(ix, row)| self.render_row(ix, row, cx)).collect();
        let message = self.message();

        v_flex()
            .w(px(560.))
            .max_h(px(520.))
            .p(px(8.))
            .gap(px(6.))
            .rounded(px(18.))
            .bg(p.menu_bg)
            .shadow(p.raise.clone())
            // Clicks in the panel shouldn't reach the backdrop behind it.
            .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
            // The field keeps focus; these keys drive the list instead.
            .capture_action(cx.listener(|this, _: &MoveUp, _, cx| {
                cx.stop_propagation();
                this.move_highlight(-1, cx);
            }))
            .capture_action(cx.listener(|this, _: &MoveDown, _, cx| {
                cx.stop_propagation();
                this.move_highlight(1, cx);
            }))
            .capture_action(cx.listener(|this, _: &IndentInline, window, cx| {
                cx.stop_propagation();
                this.complete(window, cx);
            }))
            .capture_action(cx.listener(|_, _: &Escape, _, cx| {
                cx.stop_propagation();
                cx.emit(FolderBrowserEvent::Cancelled);
            }))
            .capture_action(cx.listener(|this, enter: &Enter, window, cx| {
                cx.stop_propagation();
                if enter.secondary {
                    if let Some(path) = this.target(cx) {
                        this.pick(path, cx);
                    }
                } else if let Some(&row) = this.rows.get(this.highlight) {
                    this.open(row, window, cx);
                }
            }))
            .child(
                h_flex()
                    .flex_none()
                    .h(px(38.))
                    .pl(px(11.))
                    .pr(px(8.))
                    .gap(px(8.))
                    .rounded(px(13.))
                    .well(p)
                    .font_family(metal::MONO)
                    .text_size(px(13.))
                    .text_color(p.ink)
                    .child(Icon::new(IconName::FolderOpen).size(px(14.)).text_color(p.field_ink))
                    .child(div().flex_1().min_w_0().child(Input::new(&self.input).appearance(false)))
                    .child(metal::shortcut(&["Tab"], p)),
            )
            .child(
                v_flex()
                    .id("folder-list")
                    .h(px(340.))
                    .overflow_y_scroll()
                    .track_scroll(&self.scroll)
                    .gap(px(1.))
                    .children(rows)
                    .when_some(message, |this, message| {
                        this.child(
                            div()
                                .px(px(10.))
                                .py(px(10.))
                                .text_size(px(12.5))
                                .line_height(px(18.))
                                .text_color(p.ink3)
                                .child(message),
                        )
                    }),
            )
            .child(div().mx(px(4.)).child(metal::rule(p)))
            .child(
                h_flex()
                    .flex_none()
                    .gap(px(6.))
                    .pl(px(6.))
                    .child(div().flex_1().min_w_0().truncate().engraved(p).child(footer_path))
                    .child(
                        MetalButton::new("folder-system-dialog")
                            .ghost()
                            .compact()
                            .label("Use system dialog…")
                            .on_click(cx.listener(|_, _, _, cx| cx.emit(FolderBrowserEvent::SystemDialog))),
                    )
                    .child(
                        MetalButton::new("folder-cancel")
                            .compact()
                            .label("Cancel")
                            .on_click(cx.listener(|_, _, _, cx| cx.emit(FolderBrowserEvent::Cancelled))),
                    )
                    .child(
                        MetalButton::new("folder-pick")
                            .primary()
                            .compact()
                            .label("Add project")
                            .tooltip("Add the folder in the field (Ctrl+Enter)")
                            .disabled(target.is_none())
                            .on_click(cx.listener(move |this, _, _, cx| {
                                if let Some(path) = target.clone() {
                                    this.pick(path, cx);
                                }
                            })),
                    ),
            )
    }
}

/// 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,
    }
}

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.
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.
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.
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 `$HOME/Work` filtered by `vec`.
fn split_query(text: &str, home: &Path) -> (PathBuf, String) {
    match text.rfind('/') {
        _ if text == "~" => (home.to_path_buf(), String::new()),
        Some(ix) => (expand(&text[..=ix], home), text[ix + 1..].to_string()),
        None => (home.to_path_buf(), 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::{complete_name, expand, field_text, filter_names, home_dir, split_query, 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() {
        let home = Path::new(HOME);
        assert_eq!(split_query("~/Work/vec", home), (PathBuf::from("/home/ada/Work"), "vec".into()));
        assert_eq!(split_query("~/Work/", home), (PathBuf::from("/home/ada/Work"), String::new()));
        assert_eq!(split_query("~", home), (PathBuf::from(HOME), String::new()));
        assert_eq!(split_query("", home), (PathBuf::from(HOME), String::new()));
        assert_eq!(split_query("/e", home), (PathBuf::from("/"), "e".into()));
    }

    #[::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()));
    }
}