//! 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, home: PathBuf, /// The folder listed. dir: PathBuf, folders: Vec, /// Why `dir` couldn't be read. error: Option, /// 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, highlight: usize, scroll: ScrollHandle, _subscription: Subscription, } impl EventEmitter 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 { 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) { 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.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) { 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) { 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) { 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) { 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 { 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) { cx.emit(FolderBrowserEvent::Picked(path)); } fn render_row(&self, ix: usize, row: Row, cx: &mut Context) -> 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 { 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) -> 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 = 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 { 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::{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::::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())); } }