//! 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()));
}
}