Vectrum
Sign In

Add project opens an in-app folder browser starting in the configured folder

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
JjohannSocommitted 1 parent a87bdd3commit35e9d52

Showing 3 changed files with 671 additions and 0 deletions.

+623−0src/ui/folder_browser.rsaddedView File
@@ -0,0 +1,623 @@
1+ //! The Add project browser: an in-app folder picker, since the system dialog
2+ //! can't be told which folder to start in. A path field on top filters and
3+ //! completes the listing below it, like a shell.
4+
5+ use std::path::{Component, Path, PathBuf};
6+
7+ use gpui_kit::component::{
8+ Icon, IconName, h_flex,
9+ input::{Enter, Escape, IndentInline, Input, InputEvent, InputState, MoveDown, MoveUp},
10+ v_flex,
11+ };
12+ use gpui_kit::prelude::FluentBuilder as _;
13+ use gpui_kit::*;
14+
15+ use crate::ui::metal::{self, MetalButton, MetalStyled as _};
16+
17+ /// The most folders one listing shows.
18+ const MAX_FOLDERS: usize = 2000;
19+ /// The most entries read from one folder, so a folder full of files stays quick.
20+ const MAX_SCANNED: usize = 20_000;
21+
22+ pub enum FolderBrowserEvent {
23+ Picked(PathBuf),
24+ Cancelled,
25+ /// The user would rather use the system's folder dialog.
26+ SystemDialog,
27+ }
28+
29+ struct Folder {
30+ name: String,
31+ /// It has a `.git`, so it's likely a project.
32+ git: bool,
33+ }
34+
35+ /// One row of the list: the parent folder, or a folder of the listing.
36+ #[derive(Clone, Copy, PartialEq, Eq)]
37+ enum Row {
38+ Up,
39+ Folder(usize),
40+ }
41+
42+ pub struct FolderBrowser {
43+ input: Entity<InputState>,
44+ home: PathBuf,
45+ /// The folder listed.
46+ dir: PathBuf,
47+ folders: Vec<Folder>,
48+ /// Why `dir` couldn't be read.
49+ error: Option<String>,
50+ /// The listing stopped at `MAX_FOLDERS`.
51+ truncated: bool,
52+ /// The field names a folder that doesn't exist.
53+ missing: bool,
54+ /// The partial name typed after the folder, which filters the listing.
55+ filter: String,
56+ rows: Vec<Row>,
57+ highlight: usize,
58+ scroll: ScrollHandle,
59+ _subscription: Subscription,
60+ }
61+
62+ impl EventEmitter<FolderBrowserEvent> for FolderBrowser {}
63+
64+ impl Focusable for FolderBrowser {
65+ fn focus_handle(&self, cx: &App) -> FocusHandle {
66+ self.input.read(cx).focus_handle(cx)
67+ }
68+ }
69+
70+ impl FolderBrowser {
71+ /// Opens on `start`, or on the home folder if `start` isn't a folder.
72+ pub fn new(start: PathBuf, window: &mut Window, cx: &mut Context<Self>) -> Self {
73+ let home = home_dir();
74+ let start = if start.is_dir() { start } else { home.clone() };
75+ let input = cx.new(|cx| {
76+ let mut input = InputState::new(window, cx).placeholder("~/");
77+ input.set_value(field_text(&start, &home), window, cx);
78+ input
79+ });
80+ let subscription = cx.subscribe(&input, |this, _, event: &InputEvent, cx| {
81+ if matches!(event, InputEvent::Change) {
82+ this.sync(cx);
83+ }
84+ });
85+ let mut browser = Self {
86+ input,
87+ home,
88+ dir: PathBuf::new(),
89+ folders: Vec::new(),
90+ error: None,
91+ truncated: false,
92+ missing: false,
93+ filter: String::new(),
94+ rows: Vec::new(),
95+ highlight: 0,
96+ scroll: ScrollHandle::new(),
97+ _subscription: subscription,
98+ };
99+ browser.sync(cx);
100+ browser
101+ }
102+
103+ /// Lists the folder the field points at, filtered by the partial name
104+ /// after its last slash.
105+ fn sync(&mut self, cx: &mut Context<Self>) {
106+ let text = self.input.read(cx).value();
107+ let (dir, filter) = split_query(&text, &self.home);
108+ self.missing = !dir.is_dir();
109+ if !self.missing && dir != self.dir {
110+ match read_folders(&dir) {
111+ Ok((folders, truncated)) => {
112+ self.folders = folders;
113+ self.truncated = truncated;
114+ self.error = None;
115+ }
116+ Err(err) => {
117+ self.folders.clear();
118+ self.truncated = false;
119+ self.error = Some(match err.kind() {
120+ std::io::ErrorKind::PermissionDenied => "Can't read this folder: permission denied.".into(),
121+ _ => format!("Can't read this folder: {err}"),
122+ });
123+ }
124+ }
125+ self.dir = dir;
126+ }
127+ self.filter = filter;
128+
129+ let names: Vec<&str> = self.folders.iter().map(|folder| folder.name.as_str()).collect();
130+ let up = !self.missing && self.filter.is_empty() && self.dir.parent().is_some();
131+ self.rows = if self.missing {
132+ Vec::new()
133+ } else {
134+ up.then_some(Row::Up)
135+ .into_iter()
136+ .chain(filter_names(&names, &self.filter).into_iter().map(Row::Folder))
137+ .collect()
138+ };
139+ // Start on the first folder rather than "..".
140+ let first = self.rows.iter().position(|row| matches!(row, Row::Folder(_))).unwrap_or(0);
141+ self.set_highlight(first, cx);
142+ }
143+
144+ fn set_highlight(&mut self, ix: usize, cx: &mut Context<Self>) {
145+ self.highlight = ix.min(self.rows.len().saturating_sub(1));
146+ self.scroll.scroll_to_item(self.highlight);
147+ cx.notify();
148+ }
149+
150+ fn move_highlight(&mut self, delta: isize, cx: &mut Context<Self>) {
151+ let ix = self.highlight.saturating_add_signed(delta);
152+ self.set_highlight(ix, cx);
153+ }
154+
155+ /// Lists `dir`, with the field pointing at it.
156+ fn navigate(&mut self, dir: &Path, window: &mut Window, cx: &mut Context<Self>) {
157+ let text = field_text(dir, &self.home);
158+ self.input.update(cx, |input, cx| {
159+ input.set_value(text, window, cx);
160+ input.focus(window, cx);
161+ });
162+ self.sync(cx);
163+ }
164+
165+ /// Opens a row: up a level (keeping the folder we came from highlighted),
166+ /// or into the folder.
167+ fn open(&mut self, row: Row, window: &mut Window, cx: &mut Context<Self>) {
168+ match row {
169+ Row::Up => {
170+ let Some(parent) = self.dir.parent().map(Path::to_path_buf) else { return };
171+ let from = self.dir.file_name().map(|name| name.to_string_lossy().into_owned());
172+ self.navigate(&parent, window, cx);
173+ let came_from = self.rows.iter().position(|row| match row {
174+ Row::Folder(ix) => Some(&self.folders[*ix].name) == from.as_ref(),
175+ Row::Up => false,
176+ });
177+ if let Some(ix) = came_from {
178+ self.set_highlight(ix, cx);
179+ }
180+ }
181+ Row::Folder(ix) => {
182+ let dir = self.dir.join(&self.folders[ix].name);
183+ self.navigate(&dir, window, cx);
184+ }
185+ }
186+ }
187+
188+ /// Tab: completes the partial name to the one folder it matches, or as
189+ /// far as all its matches agree.
190+ fn complete(&mut self, window: &mut Window, cx: &mut Context<Self>) {
191+ let names: Vec<&str> = self.folders.iter().map(|folder| folder.name.as_str()).collect();
192+ let Some(completion) = complete_name(&names, &self.filter) else { return };
193+ let text = self.input.read(cx).value();
194+ let head = text.rfind('/').map_or("", |ix| &text[..=ix]);
195+ let text = format!("{head}{completion}");
196+ self.input.update(cx, |input, cx| input.set_value(text, window, cx));
197+ self.sync(cx);
198+ }
199+
200+ /// The folder the field names, if it exists. "Add project" adds this one.
201+ fn target(&self, cx: &App) -> Option<PathBuf> {
202+ let path = expand(&self.input.read(cx).value(), &self.home);
203+ path.is_dir().then_some(path)
204+ }
205+
206+ fn pick(&mut self, path: PathBuf, cx: &mut Context<Self>) {
207+ cx.emit(FolderBrowserEvent::Picked(path));
208+ }
209+
210+ fn render_row(&self, ix: usize, row: Row, cx: &mut Context<Self>) -> AnyElement {
211+ let p = metal::palette(cx);
212+ let highlighted = ix == self.highlight;
213+ let group = SharedString::from(format!("folder-row-{ix}"));
214+ let (icon, name, git) = match row {
215+ Row::Up => (IconName::ArrowUp, "..".to_string(), false),
216+ Row::Folder(folder) => {
217+ let folder = &self.folders[folder];
218+ (IconName::Folder, folder.name.clone(), folder.git)
219+ }
220+ };
221+ h_flex()
222+ .id(("folder-row", ix))
223+ .group(group.clone())
224+ .flex_none()
225+ .h(px(32.))
226+ .px(px(10.))
227+ .gap(px(10.))
228+ .rounded(px(10.))
229+ .cursor_pointer()
230+ .hover(|style| style.bg(p.menu_row_hover))
231+ .when(highlighted, |this| this.bg(p.menu_row_hover))
232+ .on_click(cx.listener(move |this, _, window, cx| this.open(row, window, cx)))
233+ .child(Icon::new(icon).size(px(15.)).text_color(p.icon))
234+ .child(
235+ div()
236+ .flex_1()
237+ .min_w_0()
238+ .truncate()
239+ .text_size(px(13.5))
240+ .font_weight(FontWeight::MEDIUM)
241+ .text_color(p.ink)
242+ .child(name.clone()),
243+ )
244+ .when(git, |this| this.child(metal::tag("git", p)))
245+ .when(row != Row::Up, |this| {
246+ let path = self.dir.join(&name);
247+ this.child(
248+ div()
249+ .when(!highlighted, |this| this.invisible().group_hover(group, |this| this.visible()))
250+ .child(
251+ MetalButton::new(("folder-add", ix))
252+ .mini()
253+ .label("Add")
254+ .tooltip("Add this folder as a project")
255+ .on_click(cx.listener(move |this, _, _, cx| {
256+ cx.stop_propagation();
257+ this.pick(path.clone(), cx);
258+ })),
259+ ),
260+ )
261+ })
262+ .into_any_element()
263+ }
264+
265+ /// A muted line under the rows, when there's something to explain.
266+ fn message(&self) -> Option<String> {
267+ if self.missing {
268+ Some("No such folder.".into())
269+ } else if let Some(error) = &self.error {
270+ Some(error.clone())
271+ } else if self.truncated {
272+ Some(format!("Showing the first {MAX_FOLDERS} folders. Type to narrow them down."))
273+ } else if self.folders.is_empty() {
274+ Some("No folders here.".into())
275+ } else if !self.rows.iter().any(|row| matches!(row, Row::Folder(_))) {
276+ Some("No folders match.".into())
277+ } else {
278+ None
279+ }
280+ }
281+ }
282+
283+ impl Render for FolderBrowser {
284+ fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
285+ let p = metal::palette(cx);
286+ let target = self.target(cx);
287+ let footer_path = match &target {
288+ Some(path) => tilde(path, &self.home),
289+ None => "Pick a folder".into(),
290+ };
291+ let rows: Vec<AnyElement> =
292+ self.rows.clone().into_iter().enumerate().map(|(ix, row)| self.render_row(ix, row, cx)).collect();
293+ let message = self.message();
294+
295+ v_flex()
296+ .w(px(560.))
297+ .max_h(px(520.))
298+ .p(px(8.))
299+ .gap(px(6.))
300+ .rounded(px(18.))
301+ .bg(p.menu_bg)
302+ .shadow(p.raise.clone())
303+ // Clicks in the panel shouldn't reach the backdrop behind it.
304+ .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
305+ // The field keeps focus; these keys drive the list instead.
306+ .capture_action(cx.listener(|this, _: &MoveUp, _, cx| {
307+ cx.stop_propagation();
308+ this.move_highlight(-1, cx);
309+ }))
310+ .capture_action(cx.listener(|this, _: &MoveDown, _, cx| {
311+ cx.stop_propagation();
312+ this.move_highlight(1, cx);
313+ }))
314+ .capture_action(cx.listener(|this, _: &IndentInline, window, cx| {
315+ cx.stop_propagation();
316+ this.complete(window, cx);
317+ }))
318+ .capture_action(cx.listener(|_, _: &Escape, _, cx| {
319+ cx.stop_propagation();
320+ cx.emit(FolderBrowserEvent::Cancelled);
321+ }))
322+ .capture_action(cx.listener(|this, enter: &Enter, window, cx| {
323+ cx.stop_propagation();
324+ if enter.secondary {
325+ if let Some(path) = this.target(cx) {
326+ this.pick(path, cx);
327+ }
328+ } else if let Some(&row) = this.rows.get(this.highlight) {
329+ this.open(row, window, cx);
330+ }
331+ }))
332+ .child(
333+ h_flex()
334+ .flex_none()
335+ .h(px(38.))
336+ .pl(px(11.))
337+ .pr(px(8.))
338+ .gap(px(8.))
339+ .rounded(px(13.))
340+ .well(p)
341+ .font_family(metal::MONO)
342+ .text_size(px(13.))
343+ .text_color(p.ink)
344+ .child(Icon::new(IconName::FolderOpen).size(px(14.)).text_color(p.field_ink))
345+ .child(div().flex_1().min_w_0().child(Input::new(&self.input).appearance(false)))
346+ .child(metal::shortcut(&["Tab"], p)),
347+ )
348+ .child(
349+ v_flex()
350+ .id("folder-list")
351+ .h(px(340.))
352+ .overflow_y_scroll()
353+ .track_scroll(&self.scroll)
354+ .gap(px(1.))
355+ .children(rows)
356+ .when_some(message, |this, message| {
357+ this.child(
358+ div()
359+ .px(px(10.))
360+ .py(px(10.))
361+ .text_size(px(12.5))
362+ .line_height(px(18.))
363+ .text_color(p.ink3)
364+ .child(message),
365+ )
366+ }),
367+ )
368+ .child(div().mx(px(4.)).child(metal::rule(p)))
369+ .child(
370+ h_flex()
371+ .flex_none()
372+ .gap(px(6.))
373+ .pl(px(6.))
374+ .child(div().flex_1().min_w_0().truncate().engraved(p).child(footer_path))
375+ .child(
376+ MetalButton::new("folder-system-dialog")
377+ .ghost()
378+ .compact()
379+ .label("Use system dialog…")
380+ .on_click(cx.listener(|_, _, _, cx| cx.emit(FolderBrowserEvent::SystemDialog))),
381+ )
382+ .child(
383+ MetalButton::new("folder-cancel")
384+ .compact()
385+ .label("Cancel")
386+ .on_click(cx.listener(|_, _, _, cx| cx.emit(FolderBrowserEvent::Cancelled))),
387+ )
388+ .child(
389+ MetalButton::new("folder-pick")
390+ .primary()
391+ .compact()
392+ .label("Add project")
393+ .tooltip("Add the folder in the field (Ctrl+Enter)")
394+ .disabled(target.is_none())
395+ .on_click(cx.listener(move |this, _, _, cx| {
396+ if let Some(path) = target.clone() {
397+ this.pick(path, cx);
398+ }
399+ })),
400+ ),
401+ )
402+ }
403+ }
404+
405+ /// Where the Add project browser opens: the saved "Add project starts in"
406+ /// folder (`~` and `~/…` expanded), or the home folder when that's empty or
407+ /// isn't a folder.
408+ pub fn start_dir(setting: Option<&str>) -> PathBuf {
409+ let home = home_dir();
410+ match setting.map(str::trim).filter(|setting| !setting.is_empty()) {
411+ Some(setting) => Some(expand(setting, &home)).filter(|dir| dir.is_dir()).unwrap_or(home),
412+ None => home,
413+ }
414+ }
415+
416+ fn home_dir() -> PathBuf {
417+ std::env::var_os("HOME").map(PathBuf::from).unwrap_or_else(|| PathBuf::from("/"))
418+ }
419+
420+ /// Turns what's typed into an absolute path: `~` is `home`, a relative path
421+ /// is taken from `home`, and `.` and `..` are resolved.
422+ fn expand(text: &str, home: &Path) -> PathBuf {
423+ let path = if text == "~" {
424+ home.to_path_buf()
425+ } else if let Some(rest) = text.strip_prefix("~/") {
426+ home.join(rest)
427+ } else {
428+ home.join(text)
429+ };
430+ let mut normal = PathBuf::new();
431+ for component in path.components() {
432+ match component {
433+ Component::CurDir => {}
434+ Component::ParentDir => {
435+ normal.pop();
436+ }
437+ component => normal.push(component),
438+ }
439+ }
440+ normal
441+ }
442+
443+ /// A path as the field shows it, with `~` for the home folder.
444+ fn tilde(path: &Path, home: &Path) -> String {
445+ match path.strip_prefix(home) {
446+ Ok(rest) if home.parent().is_some() && rest.as_os_str().is_empty() => "~".into(),
447+ Ok(rest) if home.parent().is_some() => format!("~/{}", rest.display()),
448+ _ => path.display().to_string(),
449+ }
450+ }
451+
452+ /// The field's text for listing `dir`: its path with a trailing slash.
453+ fn field_text(dir: &Path, home: &Path) -> String {
454+ let text = tilde(dir, home);
455+ if text.ends_with('/') { text } else { format!("{text}/") }
456+ }
457+
458+ /// Splits the field into the folder to list and the partial name after it:
459+ /// `~/Work/vec` is `$HOME/Work` filtered by `vec`.
460+ fn split_query(text: &str, home: &Path) -> (PathBuf, String) {
461+ match text.rfind('/') {
462+ _ if text == "~" => (home.to_path_buf(), String::new()),
463+ Some(ix) => (expand(&text[..=ix], home), text[ix + 1..].to_string()),
464+ None => (home.to_path_buf(), text.to_string()),
465+ }
466+ }
467+
468+ /// The names matching `filter`, case-insensitively: those starting with it
469+ /// first, then those containing it. Dot-folders only show when the filter
470+ /// starts with a dot.
471+ fn filter_names(names: &[&str], filter: &str) -> Vec<usize> {
472+ let needle = filter.to_lowercase();
473+ let (mut starts, mut contains) = (Vec::new(), Vec::new());
474+ for (ix, name) in names.iter().enumerate() {
475+ if name.starts_with('.') && !filter.starts_with('.') {
476+ continue;
477+ }
478+ let name = name.to_lowercase();
479+ if name.starts_with(&needle) {
480+ starts.push(ix);
481+ } else if name.contains(&needle) {
482+ contains.push(ix);
483+ }
484+ }
485+ starts.extend(contains);
486+ starts
487+ }
488+
489+ /// What Tab turns `filter` into: the one name starting with it plus a slash,
490+ /// or the longest prefix the names starting with it share. `None` when
491+ /// that adds nothing.
492+ fn complete_name(names: &[&str], filter: &str) -> Option<String> {
493+ let needle = filter.to_lowercase();
494+ let matches: Vec<&str> = names
495+ .iter()
496+ .copied()
497+ .filter(|name| !name.starts_with('.') || filter.starts_with('.'))
498+ .filter(|name| name.to_lowercase().starts_with(&needle))
499+ .collect();
500+ match matches.as_slice() {
501+ [] => None,
502+ [only] => Some(format!("{only}/")),
503+ [first, rest @ ..] => {
504+ let len = rest.iter().fold(first.len(), |len, name| common_prefix_len(&first[..len], name));
505+ let prefix = &first[..len];
506+ (prefix.chars().count() > filter.chars().count()).then(|| prefix.to_string())
507+ }
508+ }
509+ }
510+
511+ /// How many bytes of `a` start `b` too, ignoring case.
512+ fn common_prefix_len(a: &str, b: &str) -> usize {
513+ let mut len = 0;
514+ for ((ix, x), y) in a.char_indices().zip(b.chars()) {
515+ if !x.to_lowercase().eq(y.to_lowercase()) {
516+ return ix;
517+ }
518+ len = ix + x.len_utf8();
519+ }
520+ len
521+ }
522+
523+ /// The subfolders of `dir`, sorted case-insensitively, and whether the
524+ /// listing stopped short.
525+ fn read_folders(dir: &Path) -> std::io::Result<(Vec<Folder>, bool)> {
526+ let mut folders = Vec::new();
527+ let mut truncated = false;
528+ for (scanned, entry) in std::fs::read_dir(dir)?.enumerate() {
529+ if scanned >= MAX_SCANNED || folders.len() >= MAX_FOLDERS {
530+ truncated = true;
531+ break;
532+ }
533+ let Ok(entry) = entry else { continue };
534+ let path = entry.path();
535+ // Follow symlinks, so a linked folder lists like any other.
536+ let is_dir = match entry.file_type() {
537+ Ok(kind) if kind.is_symlink() => path.is_dir(),
538+ Ok(kind) => kind.is_dir(),
539+ Err(_) => false,
540+ };
541+ if is_dir {
542+ folders.push(Folder {
543+ name: entry.file_name().to_string_lossy().into_owned(),
544+ git: path.join(".git").exists(),
545+ });
546+ }
547+ }
548+ folders.sort_by_cached_key(|folder| folder.name.to_lowercase());
549+ Ok((folders, truncated))
550+ }
551+
552+ #[cfg(test)]
553+ mod tests {
554+ // Not a glob: GPUI exports its own `test` attribute.
555+ use super::{complete_name, expand, field_text, filter_names, home_dir, split_query, start_dir, tilde};
556+ use std::path::{Path, PathBuf};
557+
558+ const HOME: &str = "/home/ada";
559+
560+ #[::core::prelude::v1::test]
561+ fn expands_paths() {
562+ let home = Path::new(HOME);
563+ assert_eq!(expand("~", home), PathBuf::from(HOME));
564+ assert_eq!(expand("~/", home), PathBuf::from(HOME));
565+ assert_eq!(expand("~/Work/rust/", home), PathBuf::from("/home/ada/Work/rust"));
566+ assert_eq!(expand("/etc", home), PathBuf::from("/etc"));
567+ assert_eq!(expand("Work", home), PathBuf::from("/home/ada/Work"));
568+ assert_eq!(expand("~/Work/../src/./x", home), PathBuf::from("/home/ada/src/x"));
569+ assert_eq!(expand("/..", home), PathBuf::from("/"));
570+ }
571+
572+ #[::core::prelude::v1::test]
573+ fn start_dir_falls_back_to_home() {
574+ assert_eq!(start_dir(None), home_dir());
575+ assert_eq!(start_dir(Some(" ")), home_dir());
576+ assert_eq!(start_dir(Some("~")), home_dir());
577+ assert_eq!(start_dir(Some("/no/such/folder/here")), home_dir());
578+ assert_eq!(start_dir(Some("/")), PathBuf::from("/"));
579+ }
580+
581+ #[::core::prelude::v1::test]
582+ fn shows_home_as_tilde() {
583+ let home = Path::new(HOME);
584+ assert_eq!(tilde(Path::new(HOME), home), "~");
585+ assert_eq!(tilde(Path::new("/home/ada/Work"), home), "~/Work");
586+ assert_eq!(tilde(Path::new("/home/adam"), home), "/home/adam");
587+ assert_eq!(tilde(Path::new("/etc"), Path::new("/")), "/etc");
588+ assert_eq!(field_text(Path::new(HOME), home), "~/");
589+ assert_eq!(field_text(Path::new("/"), home), "/");
590+ assert_eq!(field_text(Path::new("/etc"), home), "/etc/");
591+ }
592+
593+ #[::core::prelude::v1::test]
594+ fn splits_the_field() {
595+ let home = Path::new(HOME);
596+ assert_eq!(split_query("~/Work/vec", home), (PathBuf::from("/home/ada/Work"), "vec".into()));
597+ assert_eq!(split_query("~/Work/", home), (PathBuf::from("/home/ada/Work"), String::new()));
598+ assert_eq!(split_query("~", home), (PathBuf::from(HOME), String::new()));
599+ assert_eq!(split_query("", home), (PathBuf::from(HOME), String::new()));
600+ assert_eq!(split_query("/e", home), (PathBuf::from("/"), "e".into()));
601+ }
602+
603+ #[::core::prelude::v1::test]
604+ fn filters_names() {
605+ let names = [".config", "Desktop", "docs", "my-docs", "Work"];
606+ assert_eq!(filter_names(&names, ""), vec![1, 2, 3, 4]);
607+ assert_eq!(filter_names(&names, "D"), vec![1, 2, 3]);
608+ assert_eq!(filter_names(&names, "docs"), vec![2, 3]);
609+ assert_eq!(filter_names(&names, "."), vec![0]);
610+ assert_eq!(filter_names(&names, "zzz"), Vec::<usize>::new());
611+ }
612+
613+ #[::core::prelude::v1::test]
614+ fn completes_names() {
615+ let names = [".cargo", "Workbench", "workshop", "Writing"];
616+ assert_eq!(complete_name(&names, "wr"), Some("Writing/".into()));
617+ assert_eq!(complete_name(&names, "wo"), Some("Work".into()));
618+ assert_eq!(complete_name(&names, "work"), None);
619+ assert_eq!(complete_name(&names, "x"), None);
620+ assert_eq!(complete_name(&names, "."), Some(".cargo/".into()));
621+ assert_eq!(complete_name(&names, ""), Some("W".into()));
622+ }
623+ }
+1−0src/ui/mod.rsView File
@@ -1,5 +1,6 @@
11 pub mod changes;
22 pub mod chat;
3+ pub mod folder_browser;
34 pub mod icons;
45 pub mod markdown_input;
56 pub mod metal;
+47−0src/ui/workspace.rsView File
@@ -33,6 +33,7 @@ use crate::session::{NewSession, Reasoning, Session, SessionStatus, from_millis,
3333 use crate::settings::{self, BackgroundActivity, ProjectOrder, WorkspaceMode};
3434 use crate::store::{self, AppState, SavedProject};
3535 use crate::ui::chat::{ChatEvent, ChatView, display_path};
36+ use crate::ui::folder_browser::{self, FolderBrowser, FolderBrowserEvent};
3637 use crate::ui::icons::Lucide;
3738 use crate::ui::metal::{self, Led, MetalButton, MetalStyled as _, Palette};
3839 use crate::ui::pickers;
@@ -168,6 +169,8 @@ pub struct Workspace {
168169 hostname: SharedString,
169170 settings_view: Entity<SettingsView>,
170171 settings_open: bool,
172+ /// The Add project browser, while it's open.
173+ folder_browser: Option<(Entity<FolderBrowser>, Subscription)>,
171174 activity: HashMap<EntityId, Activity>,
172175 /// The thread whose settle button waits for its confirming click.
173176 settle_armed: Option<(EntityId, Instant)>,
@@ -267,6 +270,7 @@ impl Workspace {
267270 hostname: hostname().into(),
268271 settings_view,
269272 settings_open: false,
273+ folder_browser: None,
270274 activity: HashMap::new(),
271275 settle_armed: None,
272276 dirty: HashSet::new(),
@@ -1136,7 +1140,32 @@ impl Workspace {
11361140 cx.notify();
11371141 }
11381142
1143+ /// Opens the Add project browser over the window, in the folder the
1144+ /// settings name.
11391145 fn prompt_for_project(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1146+ let saved = settings::get(|settings| settings.add_project_dir.clone());
1147+ let start = folder_browser::start_dir(Some(saved.as_str()));
1148+ let browser = cx.new(|cx| FolderBrowser::new(start, window, cx));
1149+ let subscription = cx.subscribe_in(&browser, window, |this, _, event: &FolderBrowserEvent, window, cx| {
1150+ this.close_folder_browser(window, cx);
1151+ match event {
1152+ FolderBrowserEvent::Picked(path) => this.add_project(path.clone(), window, cx),
1153+ FolderBrowserEvent::Cancelled => {}
1154+ FolderBrowserEvent::SystemDialog => this.prompt_with_system_dialog(window, cx),
1155+ }
1156+ });
1157+ browser.focus_handle(cx).focus(window, cx);
1158+ self.folder_browser = Some((browser, subscription));
1159+ cx.notify();
1160+ }
1161+
1162+ fn close_folder_browser(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1163+ self.folder_browser = None;
1164+ self.focus_handle.focus(window, cx);
1165+ cx.notify();
1166+ }
1167+
1168+ fn prompt_with_system_dialog(&mut self, window: &mut Window, cx: &mut Context<Self>) {
11401169 let paths = cx.prompt_for_paths(PathPromptOptions {
11411170 files: false,
11421171 directories: true,
@@ -2379,6 +2408,24 @@ impl Render for Workspace {
23792408 ),
23802409 ),
23812410 ))
2411+ // The Add project browser floats over everything; a click beside it cancels.
2412+ .when_some(self.folder_browser.as_ref().map(|(browser, _)| browser.clone()), |this, browser| {
2413+ this.child(
2414+ div()
2415+ .absolute()
2416+ .inset_0()
2417+ .flex()
2418+ .items_center()
2419+ .justify_center()
2420+ .bg(black().opacity(if p.dark { 0.4 } else { 0.16 }))
2421+ .occlude()
2422+ .on_mouse_down(
2423+ MouseButton::Left,
2424+ cx.listener(|this, _, window, cx| this.close_folder_browser(window, cx)),
2425+ )
2426+ .child(browser),
2427+ )
2428+ })
23822429 }
23832430 }
23842431