open picker (#14524)

Conrad Irwin and Max created

Release Notes:

- linux: Added a fallback Open picker for when XDG is not working
- Added a new setting `use_system_path_prompts` (default true) that can
be disabled to use Zed's builtin keyboard-driven prompts.

---------

Co-authored-by: Max <max@zed.dev>

Change summary

Cargo.lock                                 |   1 
assets/keymaps/default-linux.json          |   8 
assets/keymaps/default-macos.json          |  16 
assets/settings/default.json               |   3 
crates/file_finder/src/file_finder.rs      |   3 
crates/file_finder/src/new_path_prompt.rs  |  14 
crates/file_finder/src/open_path_prompt.rs | 293 ++++++++++++++++++++++++
crates/menu/src/menu.rs                    |   1 
crates/picker/src/picker.rs                |  15 
crates/project/Cargo.toml                  |   1 
crates/project/src/project.rs              |  18 +
crates/tasks_ui/src/modal.rs               |  12 
crates/workspace/src/workspace.rs          | 131 +++++++--
crates/workspace/src/workspace_settings.rs |   6 
14 files changed, 453 insertions(+), 69 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -8077,6 +8077,7 @@ dependencies = [
  "serde_json",
  "settings",
  "sha2 0.10.7",
+ "shellexpand 2.1.2",
  "shlex",
  "similar",
  "smol",

assets/keymaps/default-linux.json 🔗

@@ -19,7 +19,6 @@
       "escape": "menu::Cancel",
       "ctrl-escape": "menu::Cancel",
       "ctrl-c": "menu::Cancel",
-      "shift-enter": "picker::UseSelectedQuery",
       "alt-enter": ["picker::ConfirmInput", { "secondary": false }],
       "ctrl-alt-enter": ["picker::ConfirmInput", { "secondary": true }],
       "ctrl-shift-w": "workspace::CloseWindow",
@@ -567,6 +566,13 @@
       "tab": "channel_modal::ToggleMode"
     }
   },
+  {
+    "context": "Picker > Editor",
+    "bindings": {
+      "tab": "picker::ConfirmCompletion",
+      "alt-enter": ["picker::ConfirmInput", { "secondary": false }]
+    }
+  },
   {
     "context": "ChannelModal > Picker > Editor",
     "bindings": {

assets/keymaps/default-macos.json 🔗

@@ -594,6 +594,14 @@
       "tab": "channel_modal::ToggleMode"
     }
   },
+  {
+    "context": "Picker > Editor",
+    "bindings": {
+      "tab": "picker::ConfirmCompletion",
+      "alt-enter": ["picker::ConfirmInput", { "secondary": false }],
+      "cmd-alt-enter": ["picker::ConfirmInput", { "secondary": true }]
+    }
+  },
   {
     "context": "ChannelModal > Picker > Editor",
     "bindings": {
@@ -613,14 +621,6 @@
       "ctrl-backspace": "tab_switcher::CloseSelectedItem"
     }
   },
-  {
-    "context": "Picker",
-    "bindings": {
-      "f2": "picker::UseSelectedQuery",
-      "alt-enter": ["picker::ConfirmInput", { "secondary": false }],
-      "cmd-alt-enter": ["picker::ConfirmInput", { "secondary": true }]
-    }
-  },
   {
     "context": "Terminal",
     "bindings": {

assets/settings/default.json 🔗

@@ -94,6 +94,9 @@
   //  3. Never close the window
   //         "when_closing_with_no_tabs": "keep_window_open",
   "when_closing_with_no_tabs": "platform_default",
+  // Whether to use the system provided dialogs for Open and Save As.
+  // When set to false, Zed will use the built-in keyboard-first pickers.
+  "use_system_path_prompts": true,
   // Whether the cursor blinks in the editor.
   "cursor_blink": true,
   // How to highlight the current line in the editor.

crates/file_finder/src/file_finder.rs 🔗

@@ -2,6 +2,7 @@
 mod file_finder_tests;
 
 mod new_path_prompt;
+mod open_path_prompt;
 
 use collections::{BTreeSet, HashMap};
 use editor::{scroll::Autoscroll, Bias, Editor};
@@ -13,6 +14,7 @@ use gpui::{
 };
 use itertools::Itertools;
 use new_path_prompt::NewPathPrompt;
+use open_path_prompt::OpenPathPrompt;
 use picker::{Picker, PickerDelegate};
 use project::{PathMatchCandidateSet, Project, ProjectPath, WorktreeId};
 use settings::Settings;
@@ -41,6 +43,7 @@ pub struct FileFinder {
 pub fn init(cx: &mut AppContext) {
     cx.observe_new_views(FileFinder::register).detach();
     cx.observe_new_views(NewPathPrompt::register).detach();
+    cx.observe_new_views(OpenPathPrompt::register).detach();
 }
 
 impl FileFinder {

crates/file_finder/src/new_path_prompt.rs 🔗

@@ -197,14 +197,12 @@ pub struct NewPathDelegate {
 }
 
 impl NewPathPrompt {
-    pub(crate) fn register(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) {
-        if workspace.project().read(cx).is_remote() {
-            workspace.set_prompt_for_new_path(Box::new(|workspace, cx| {
-                let (tx, rx) = futures::channel::oneshot::channel();
-                Self::prompt_for_new_path(workspace, tx, cx);
-                rx
-            }));
-        }
+    pub(crate) fn register(workspace: &mut Workspace, _cx: &mut ViewContext<Workspace>) {
+        workspace.set_prompt_for_new_path(Box::new(|workspace, cx| {
+            let (tx, rx) = futures::channel::oneshot::channel();
+            Self::prompt_for_new_path(workspace, tx, cx);
+            rx
+        }));
     }
 
     fn prompt_for_new_path(

crates/file_finder/src/open_path_prompt.rs 🔗

@@ -0,0 +1,293 @@
+use futures::channel::oneshot;
+use fuzzy::StringMatchCandidate;
+use gpui::Model;
+use picker::{Picker, PickerDelegate};
+use project::{compare_paths, Project};
+use std::{
+    path::{Path, PathBuf},
+    sync::{
+        atomic::{self, AtomicBool},
+        Arc,
+    },
+};
+use ui::{prelude::*, LabelLike, ListItemSpacing};
+use ui::{ListItem, ViewContext};
+use util::maybe;
+use workspace::Workspace;
+
+pub(crate) struct OpenPathPrompt;
+
+pub struct OpenPathDelegate {
+    tx: Option<oneshot::Sender<Option<Vec<PathBuf>>>>,
+    project: Model<Project>,
+    selected_index: usize,
+    directory_state: Option<DirectoryState>,
+    matches: Vec<usize>,
+    cancel_flag: Arc<AtomicBool>,
+    should_dismiss: bool,
+}
+
+struct DirectoryState {
+    path: String,
+    match_candidates: Vec<StringMatchCandidate>,
+    error: Option<SharedString>,
+}
+
+impl OpenPathPrompt {
+    pub(crate) fn register(workspace: &mut Workspace, _: &mut ViewContext<Workspace>) {
+        workspace.set_prompt_for_open_path(Box::new(|workspace, cx| {
+            let (tx, rx) = futures::channel::oneshot::channel();
+            Self::prompt_for_open_path(workspace, tx, cx);
+            rx
+        }));
+    }
+
+    fn prompt_for_open_path(
+        workspace: &mut Workspace,
+        tx: oneshot::Sender<Option<Vec<PathBuf>>>,
+        cx: &mut ViewContext<Workspace>,
+    ) {
+        let project = workspace.project().clone();
+        workspace.toggle_modal(cx, |cx| {
+            let delegate = OpenPathDelegate {
+                tx: Some(tx),
+                project: project.clone(),
+                selected_index: 0,
+                directory_state: None,
+                matches: Vec::new(),
+                cancel_flag: Arc::new(AtomicBool::new(false)),
+                should_dismiss: true,
+            };
+
+            let picker = Picker::uniform_list(delegate, cx).width(rems(34.));
+            let query = if let Some(worktree) = project.read(cx).visible_worktrees(cx).next() {
+                worktree.read(cx).abs_path().to_string_lossy().to_string()
+            } else {
+                "~/".to_string()
+            };
+            picker.set_query(query, cx);
+            picker
+        });
+    }
+}
+
+impl PickerDelegate for OpenPathDelegate {
+    type ListItem = ui::ListItem;
+
+    fn match_count(&self) -> usize {
+        self.matches.len()
+    }
+
+    fn selected_index(&self) -> usize {
+        self.selected_index
+    }
+
+    fn set_selected_index(&mut self, ix: usize, cx: &mut ViewContext<Picker<Self>>) {
+        self.selected_index = ix;
+        cx.notify();
+    }
+
+    fn update_matches(
+        &mut self,
+        query: String,
+        cx: &mut ViewContext<Picker<Self>>,
+    ) -> gpui::Task<()> {
+        let project = self.project.clone();
+        let (mut dir, suffix) = if let Some(index) = query.rfind('/') {
+            (query[..index].to_string(), query[index + 1..].to_string())
+        } else {
+            (query, String::new())
+        };
+        if dir == "" {
+            dir = "/".to_string();
+        }
+
+        let query = if self
+            .directory_state
+            .as_ref()
+            .map_or(false, |s| s.path == dir)
+        {
+            None
+        } else {
+            Some(project.update(cx, |project, cx| {
+                project.completions_for_open_path_query(dir.clone(), cx)
+            }))
+        };
+        self.cancel_flag.store(true, atomic::Ordering::Relaxed);
+        self.cancel_flag = Arc::new(AtomicBool::new(false));
+        let cancel_flag = self.cancel_flag.clone();
+
+        cx.spawn(|this, mut cx| async move {
+            if let Some(query) = query {
+                let paths = query.await;
+                if cancel_flag.load(atomic::Ordering::Relaxed) {
+                    return;
+                }
+
+                this.update(&mut cx, |this, _| {
+                    this.delegate.directory_state = Some(match paths {
+                        Ok(mut paths) => {
+                            paths.sort_by(|a, b| {
+                                compare_paths(
+                                    (a.strip_prefix(&dir).unwrap_or(Path::new("")), true),
+                                    (b.strip_prefix(&dir).unwrap_or(Path::new("")), true),
+                                )
+                            });
+                            let match_candidates = paths
+                                .iter()
+                                .enumerate()
+                                .filter_map(|(ix, path)| {
+                                    Some(StringMatchCandidate::new(
+                                        ix,
+                                        path.file_name()?.to_string_lossy().into(),
+                                    ))
+                                })
+                                .collect::<Vec<_>>();
+
+                            DirectoryState {
+                                match_candidates,
+                                path: dir,
+                                error: None,
+                            }
+                        }
+                        Err(err) => DirectoryState {
+                            match_candidates: vec![],
+                            path: dir,
+                            error: Some(err.to_string().into()),
+                        },
+                    });
+                })
+                .ok();
+            }
+
+            let match_candidates = this
+                .update(&mut cx, |this, cx| {
+                    let directory_state = this.delegate.directory_state.as_ref()?;
+                    if directory_state.error.is_some() {
+                        this.delegate.matches.clear();
+                        this.delegate.selected_index = 0;
+                        cx.notify();
+                        return None;
+                    }
+
+                    Some(directory_state.match_candidates.clone())
+                })
+                .unwrap_or(None);
+
+            let Some(mut match_candidates) = match_candidates else {
+                return;
+            };
+
+            if !suffix.starts_with('.') {
+                match_candidates.retain(|m| !m.string.starts_with('.'));
+            }
+
+            if suffix == "" {
+                this.update(&mut cx, |this, cx| {
+                    this.delegate.matches.clear();
+                    this.delegate
+                        .matches
+                        .extend(match_candidates.iter().map(|m| m.id));
+
+                    cx.notify();
+                })
+                .ok();
+                return;
+            }
+
+            let matches = fuzzy::match_strings(
+                &match_candidates.as_slice(),
+                &suffix,
+                false,
+                100,
+                &cancel_flag,
+                cx.background_executor().clone(),
+            )
+            .await;
+            if cancel_flag.load(atomic::Ordering::Relaxed) {
+                return;
+            }
+
+            this.update(&mut cx, |this, cx| {
+                this.delegate.matches.clear();
+                this.delegate
+                    .matches
+                    .extend(matches.into_iter().map(|m| m.candidate_id));
+                this.delegate.matches.sort();
+                cx.notify();
+            })
+            .ok();
+        })
+    }
+
+    fn confirm_completion(&self, query: String) -> Option<String> {
+        Some(
+            maybe!({
+                let m = self.matches.get(self.selected_index)?;
+                let directory_state = self.directory_state.as_ref()?;
+                let candidate = directory_state.match_candidates.get(*m)?;
+                Some(format!("{}/{}", directory_state.path, candidate.string))
+            })
+            .unwrap_or(query),
+        )
+    }
+
+    fn confirm(&mut self, _: bool, cx: &mut ViewContext<Picker<Self>>) {
+        let Some(m) = self.matches.get(self.selected_index) else {
+            return;
+        };
+        let Some(directory_state) = self.directory_state.as_ref() else {
+            return;
+        };
+        let Some(candidate) = directory_state.match_candidates.get(*m) else {
+            return;
+        };
+        let result = Path::new(&directory_state.path).join(&candidate.string);
+        if let Some(tx) = self.tx.take() {
+            tx.send(Some(vec![result])).ok();
+        }
+        cx.emit(gpui::DismissEvent);
+    }
+
+    fn should_dismiss(&self) -> bool {
+        self.should_dismiss
+    }
+
+    fn dismissed(&mut self, cx: &mut ViewContext<Picker<Self>>) {
+        if let Some(tx) = self.tx.take() {
+            tx.send(None).ok();
+        }
+        cx.emit(gpui::DismissEvent)
+    }
+
+    fn render_match(
+        &self,
+        ix: usize,
+        selected: bool,
+        _: &mut ViewContext<Picker<Self>>,
+    ) -> Option<Self::ListItem> {
+        let m = self.matches.get(ix)?;
+        let directory_state = self.directory_state.as_ref()?;
+        let candidate = directory_state.match_candidates.get(*m)?;
+
+        Some(
+            ListItem::new(ix)
+                .spacing(ListItemSpacing::Sparse)
+                .inset(true)
+                .selected(selected)
+                .child(LabelLike::new().child(candidate.string.clone())),
+        )
+    }
+
+    fn no_matches_text(&self, _cx: &mut WindowContext) -> SharedString {
+        if let Some(error) = self.directory_state.as_ref().and_then(|s| s.error.clone()) {
+            error
+        } else {
+            "No such file or directory".into()
+        }
+    }
+
+    fn placeholder_text(&self, _cx: &mut WindowContext) -> Arc<str> {
+        Arc::from("[directory/]filename.ext")
+    }
+}

crates/picker/src/picker.rs 🔗

@@ -20,7 +20,7 @@ enum ElementContainer {
     UniformList(UniformListScrollHandle),
 }
 
-actions!(picker, [UseSelectedQuery]);
+actions!(picker, [ConfirmCompletion]);
 
 /// ConfirmInput is an alternative editor action which - instead of selecting active picker entry - treats pickers editor input literally,
 /// performing some kind of action on it.
@@ -87,10 +87,10 @@ pub trait PickerDelegate: Sized + 'static {
         false
     }
 
+    /// Override if you want to have <enter> update the query instead of confirming.
     fn confirm_update_query(&mut self, _cx: &mut ViewContext<Picker<Self>>) -> Option<String> {
         None
     }
-
     fn confirm(&mut self, secondary: bool, cx: &mut ViewContext<Picker<Self>>);
     /// Instead of interacting with currently selected entry, treats editor input literally,
     /// performing some kind of action on it.
@@ -99,7 +99,7 @@ pub trait PickerDelegate: Sized + 'static {
     fn should_dismiss(&self) -> bool {
         true
     }
-    fn selected_as_query(&self) -> Option<String> {
+    fn confirm_completion(&self, _query: String) -> Option<String> {
         None
     }
 
@@ -349,10 +349,11 @@ impl<D: PickerDelegate> Picker<D> {
         self.delegate.confirm_input(input.secondary, cx);
     }
 
-    fn use_selected_query(&mut self, _: &UseSelectedQuery, cx: &mut ViewContext<Self>) {
-        if let Some(new_query) = self.delegate.selected_as_query() {
+    fn confirm_completion(&mut self, _: &ConfirmCompletion, cx: &mut ViewContext<Self>) {
+        if let Some(new_query) = self.delegate.confirm_completion(self.query(cx)) {
             self.set_query(new_query, cx);
-            cx.stop_propagation();
+        } else {
+            cx.propagate()
         }
     }
 
@@ -571,7 +572,7 @@ impl<D: PickerDelegate> Render for Picker<D> {
             .on_action(cx.listener(Self::cancel))
             .on_action(cx.listener(Self::confirm))
             .on_action(cx.listener(Self::secondary_confirm))
-            .on_action(cx.listener(Self::use_selected_query))
+            .on_action(cx.listener(Self::confirm_completion))
             .on_action(cx.listener(Self::confirm_input))
             .child(match &self.head {
                 Head::Editor(editor) => self.delegate.render_editor(&editor.clone(), cx),

crates/project/Cargo.toml 🔗

@@ -59,6 +59,7 @@ serde.workspace = true
 serde_json.workspace = true
 settings.workspace = true
 sha2.workspace = true
+shellexpand.workspace = true
 shlex.workspace = true
 similar = "1.3"
 smol.workspace = true

crates/project/src/project.rs 🔗

@@ -7559,6 +7559,24 @@ impl Project {
         }
     }
 
+    pub fn completions_for_open_path_query(
+        &self,
+        query: String,
+        cx: &mut ModelContext<Self>,
+    ) -> Task<Result<Vec<PathBuf>>> {
+        let fs = self.fs.clone();
+        cx.background_executor().spawn(async move {
+            let mut results = vec![];
+            let expanded = shellexpand::tilde(&query);
+            let query = Path::new(expanded.as_ref());
+            let mut response = fs.read_dir(query).await?;
+            while let Some(path) = response.next().await {
+                results.push(path?);
+            }
+            Ok(results)
+        })
+    }
+
     fn create_local_worktree(
         &mut self,
         abs_path: impl AsRef<Path>,

crates/tasks_ui/src/modal.rs 🔗

@@ -462,7 +462,7 @@ impl PickerDelegate for TasksModalDelegate {
         )
     }
 
-    fn selected_as_query(&self) -> Option<String> {
+    fn confirm_completion(&self, _: String) -> Option<String> {
         let task_index = self.matches.get(self.selected_index())?.candidate_id;
         let tasks = self.candidates.as_ref()?;
         let (_, task) = tasks.get(task_index)?;
@@ -491,11 +491,7 @@ impl PickerDelegate for TasksModalDelegate {
     fn render_footer(&self, cx: &mut ViewContext<Picker<Self>>) -> Option<gpui::AnyElement> {
         let is_recent_selected = self.divider_index >= Some(self.selected_index);
         let current_modifiers = cx.modifiers();
-        let left_button = if is_recent_selected {
-            Some(("Edit task", picker::UseSelectedQuery.boxed_clone()))
-        } else if !self.matches.is_empty() {
-            Some(("Edit template", picker::UseSelectedQuery.boxed_clone()))
-        } else if self
+        let left_button = if self
             .project
             .read(cx)
             .task_inventory()
@@ -663,7 +659,7 @@ mod tests {
             "Only one task should match the query {query_str}"
         );
 
-        cx.dispatch_action(picker::UseSelectedQuery);
+        cx.dispatch_action(picker::ConfirmCompletion);
         assert_eq!(
             query(&tasks_picker, cx),
             "echo 4",
@@ -710,7 +706,7 @@ mod tests {
             "Last recently used one show task should be listed first"
         );
 
-        cx.dispatch_action(picker::UseSelectedQuery);
+        cx.dispatch_action(picker::ConfirmCompletion);
         assert_eq!(
             query(&tasks_picker, cx),
             query_str,

crates/workspace/src/workspace.rs 🔗

@@ -604,6 +604,10 @@ type PromptForNewPath = Box<
     dyn Fn(&mut Workspace, &mut ViewContext<Workspace>) -> oneshot::Receiver<Option<ProjectPath>>,
 >;
 
+type PromptForOpenPath = Box<
+    dyn Fn(&mut Workspace, &mut ViewContext<Workspace>) -> oneshot::Receiver<Option<Vec<PathBuf>>>,
+>;
+
 /// Collects everything project-related for a certain window opened.
 /// In some way, is a counterpart of a window, as the [`WindowHandle`] could be downcast into `Workspace`.
 ///
@@ -646,6 +650,7 @@ pub struct Workspace {
     centered_layout: bool,
     bounds_save_task_queued: Option<Task<()>>,
     on_prompt_for_new_path: Option<PromptForNewPath>,
+    on_prompt_for_open_path: Option<PromptForOpenPath>,
     render_disconnected_overlay:
         Option<Box<dyn Fn(&mut Self, &mut ViewContext<Self>) -> AnyElement>>,
 }
@@ -931,6 +936,7 @@ impl Workspace {
             centered_layout: false,
             bounds_save_task_queued: None,
             on_prompt_for_new_path: None,
+            on_prompt_for_open_path: None,
             render_disconnected_overlay: None,
         }
     }
@@ -1312,6 +1318,10 @@ impl Workspace {
         self.on_prompt_for_new_path = Some(prompt)
     }
 
+    pub fn set_prompt_for_open_path(&mut self, prompt: PromptForOpenPath) {
+        self.on_prompt_for_open_path = Some(prompt)
+    }
+
     pub fn set_render_disconnected_overlay(
         &mut self,
         render: impl Fn(&mut Self, &mut ViewContext<Self>) -> AnyElement + 'static,
@@ -1319,11 +1329,60 @@ impl Workspace {
         self.render_disconnected_overlay = Some(Box::new(render))
     }
 
+    pub fn prompt_for_open_path(
+        &mut self,
+        path_prompt_options: PathPromptOptions,
+        cx: &mut ViewContext<Self>,
+    ) -> oneshot::Receiver<Option<Vec<PathBuf>>> {
+        if self.project.read(cx).is_remote()
+            || !WorkspaceSettings::get_global(cx).use_system_path_prompts
+        {
+            let prompt = self.on_prompt_for_open_path.take().unwrap();
+            let rx = prompt(self, cx);
+            self.on_prompt_for_open_path = Some(prompt);
+            rx
+        } else {
+            let (tx, rx) = oneshot::channel();
+            let abs_path = cx.prompt_for_paths(path_prompt_options);
+
+            cx.spawn(|this, mut cx| async move {
+                let Ok(result) = abs_path.await else {
+                    return Ok(());
+                };
+
+                match result {
+                    Ok(result) => {
+                        tx.send(result).log_err();
+                    }
+                    Err(err) => {
+                        let rx = this.update(&mut cx, |this, cx| {
+                            this.show_portal_error(err.to_string(), cx);
+                            let prompt = this.on_prompt_for_open_path.take().unwrap();
+                            let rx = prompt(this, cx);
+                            this.on_prompt_for_open_path = Some(prompt);
+                            rx
+                        })?;
+                        if let Ok(path) = rx.await {
+                            tx.send(path).log_err();
+                        }
+                    }
+                };
+                anyhow::Ok(())
+            })
+            .detach();
+
+            rx
+        }
+    }
+
     pub fn prompt_for_new_path(
         &mut self,
         cx: &mut ViewContext<Self>,
     ) -> oneshot::Receiver<Option<ProjectPath>> {
-        if let Some(prompt) = self.on_prompt_for_new_path.take() {
+        if self.project.read(cx).is_remote()
+            || !WorkspaceSettings::get_global(cx).use_system_path_prompts
+        {
+            let prompt = self.on_prompt_for_new_path.take().unwrap();
             let rx = prompt(self, cx);
             self.on_prompt_for_new_path = Some(prompt);
             rx
@@ -1339,14 +1398,23 @@ impl Workspace {
             let (tx, rx) = oneshot::channel();
             let abs_path = cx.prompt_for_new_path(&start_abs_path);
             cx.spawn(|this, mut cx| async move {
-                let abs_path: Option<PathBuf> =
-                    Flatten::flatten(abs_path.await.map_err(|e| e.into())).map_err(|err| {
-                        this.update(&mut cx, |this, cx| {
+                let abs_path = match abs_path.await? {
+                    Ok(path) => path,
+                    Err(err) => {
+                        let rx = this.update(&mut cx, |this, cx| {
                             this.show_portal_error(err.to_string(), cx);
-                        })
-                        .ok();
-                        err
-                    })?;
+
+                            let prompt = this.on_prompt_for_new_path.take().unwrap();
+                            let rx = prompt(this, cx);
+                            this.on_prompt_for_new_path = Some(prompt);
+                            rx
+                        })?;
+                        if let Ok(path) = rx.await {
+                            tx.send(path).log_err();
+                        }
+                        return anyhow::Ok(());
+                    }
+                };
 
                 let project_path = abs_path.and_then(|abs_path| {
                     this.update(&mut cx, |this, cx| {
@@ -1629,23 +1697,18 @@ impl Workspace {
         self.client()
             .telemetry()
             .report_app_event("open project".to_string());
-        let paths = cx.prompt_for_paths(PathPromptOptions {
-            files: true,
-            directories: true,
-            multiple: true,
-        });
+        let paths = self.prompt_for_open_path(
+            PathPromptOptions {
+                files: true,
+                directories: true,
+                multiple: true,
+            },
+            cx,
+        );
 
         cx.spawn(|this, mut cx| async move {
-            let paths = match Flatten::flatten(paths.await.map_err(|e| e.into())) {
-                Ok(Some(paths)) => paths,
-                Ok(None) => return,
-                Err(err) => {
-                    this.update(&mut cx, |this, cx| {
-                        this.show_portal_error(err.to_string(), cx);
-                    })
-                    .ok();
-                    return;
-                }
+            let Some(paths) = paths.await.log_err().flatten() else {
+                return;
             };
 
             if let Some(task) = this
@@ -1801,20 +1864,16 @@ impl Workspace {
             );
             return;
         }
-        let paths = cx.prompt_for_paths(PathPromptOptions {
-            files: false,
-            directories: true,
-            multiple: true,
-        });
+        let paths = self.prompt_for_open_path(
+            PathPromptOptions {
+                files: false,
+                directories: true,
+                multiple: true,
+            },
+            cx,
+        );
         cx.spawn(|this, mut cx| async move {
-            let paths = Flatten::flatten(paths.await.map_err(|e| e.into())).map_err(|err| {
-                this.update(&mut cx, |this, cx| {
-                    this.show_portal_error(err.to_string(), cx);
-                })
-                .ok();
-                err
-            })?;
-            if let Some(paths) = paths {
+            if let Some(paths) = paths.await.log_err().flatten() {
                 let results = this
                     .update(&mut cx, |this, cx| {
                         this.open_paths(paths, OpenVisible::All, None, cx)

crates/workspace/src/workspace_settings.rs 🔗

@@ -14,6 +14,7 @@ pub struct WorkspaceSettings {
     pub restore_on_startup: RestoreOnStartupBehaviour,
     pub drop_target_size: f32,
     pub when_closing_with_no_tabs: CloseWindowWhenNoItems,
+    pub use_system_path_prompts: bool,
 }
 
 #[derive(Copy, Clone, Default, Serialize, Deserialize, JsonSchema)]
@@ -83,6 +84,11 @@ pub struct WorkspaceSettingsContent {
     ///
     /// Default: auto ("on" on macOS, "off" otherwise)
     pub when_closing_with_no_tabs: Option<CloseWindowWhenNoItems>,
+    /// Whether to use the system provided dialogs for Open and Save As.
+    /// When set to false, Zed will use the built-in keyboard-first pickers.
+    ///
+    /// Default: true
+    pub use_system_path_prompts: Option<bool>,
 }
 
 #[derive(Deserialize)]