Improve Zed prompts for file path selection (#32014)

Kirill Bulatov and Smit Barmase created

Part of https://github.com/zed-industries/zed/discussions/31653
`"use_system_path_prompts": false` is needed in settings for these to
appear as modals for new file save and file open.

Fixed a very subpar experience of the "save new file" Zed modal,
compared to a similar "open file path" Zed modal by uniting their code.

Before:


https://github.com/user-attachments/assets/c4082b70-6cdc-4598-a416-d491011c8ac4


After:



https://github.com/user-attachments/assets/21ca672a-ae40-426c-b68f-9efee4f93c8c


Also 

* alters both prompts to start in the current worktree directory, with
the fallback to home directory.
* adjusts the code to handle Windows paths better

Release Notes:

- Improved Zed prompts for file path selection

---------

Co-authored-by: Smit Barmase <heysmitbarmase@gmail.com>

Change summary

crates/extensions_ui/src/extensions_ui.rs        |   5 
crates/file_finder/src/file_finder.rs            |   4 
crates/file_finder/src/new_path_prompt.rs        | 526 ------------
crates/file_finder/src/open_path_prompt.rs       | 735 +++++++++++++----
crates/file_finder/src/open_path_prompt_tests.rs |  57 +
crates/project/src/project.rs                    |  47 
crates/recent_projects/src/remote_servers.rs     |   2 
crates/workspace/src/pane.rs                     |  48 
crates/workspace/src/workspace.rs                |  86 -
crates/zed/src/zed.rs                            |   5 
10 files changed, 722 insertions(+), 793 deletions(-)

Detailed changes

crates/extensions_ui/src/extensions_ui.rs πŸ”—

@@ -101,7 +101,10 @@ pub fn init(cx: &mut App) {
                         directories: true,
                         multiple: false,
                     },
-                    DirectoryLister::Local(workspace.app_state().fs.clone()),
+                    DirectoryLister::Local(
+                        workspace.project().clone(),
+                        workspace.app_state().fs.clone(),
+                    ),
                     window,
                     cx,
                 );

crates/file_finder/src/file_finder.rs πŸ”—

@@ -4,7 +4,6 @@ mod file_finder_tests;
 mod open_path_prompt_tests;
 
 pub mod file_finder_settings;
-mod new_path_prompt;
 mod open_path_prompt;
 
 use futures::future::join_all;
@@ -20,7 +19,6 @@ use gpui::{
     KeyContext, Modifiers, ModifiersChangedEvent, ParentElement, Render, Styled, Task, WeakEntity,
     Window, actions,
 };
-use new_path_prompt::NewPathPrompt;
 use open_path_prompt::OpenPathPrompt;
 use picker::{Picker, PickerDelegate};
 use project::{PathMatchCandidateSet, Project, ProjectPath, WorktreeId};
@@ -85,8 +83,8 @@ pub fn init_settings(cx: &mut App) {
 pub fn init(cx: &mut App) {
     init_settings(cx);
     cx.observe_new(FileFinder::register).detach();
-    cx.observe_new(NewPathPrompt::register).detach();
     cx.observe_new(OpenPathPrompt::register).detach();
+    cx.observe_new(OpenPathPrompt::register_new_path).detach();
 }
 
 impl FileFinder {

crates/file_finder/src/new_path_prompt.rs πŸ”—

@@ -1,526 +0,0 @@
-use futures::channel::oneshot;
-use fuzzy::PathMatch;
-use gpui::{Entity, HighlightStyle, StyledText};
-use picker::{Picker, PickerDelegate};
-use project::{Entry, PathMatchCandidateSet, Project, ProjectPath, WorktreeId};
-use std::{
-    path::{Path, PathBuf},
-    sync::{
-        Arc,
-        atomic::{self, AtomicBool},
-    },
-};
-use ui::{Context, ListItem, Window};
-use ui::{LabelLike, ListItemSpacing, highlight_ranges, prelude::*};
-use util::ResultExt;
-use workspace::Workspace;
-
-pub(crate) struct NewPathPrompt;
-
-#[derive(Debug, Clone)]
-struct Match {
-    path_match: Option<PathMatch>,
-    suffix: Option<String>,
-}
-
-impl Match {
-    fn entry<'a>(&'a self, project: &'a Project, cx: &'a App) -> Option<&'a Entry> {
-        if let Some(suffix) = &self.suffix {
-            let (worktree, path) = if let Some(path_match) = &self.path_match {
-                (
-                    project.worktree_for_id(WorktreeId::from_usize(path_match.worktree_id), cx),
-                    path_match.path.join(suffix),
-                )
-            } else {
-                (project.worktrees(cx).next(), PathBuf::from(suffix))
-            };
-
-            worktree.and_then(|worktree| worktree.read(cx).entry_for_path(path))
-        } else if let Some(path_match) = &self.path_match {
-            let worktree =
-                project.worktree_for_id(WorktreeId::from_usize(path_match.worktree_id), cx)?;
-            worktree.read(cx).entry_for_path(path_match.path.as_ref())
-        } else {
-            None
-        }
-    }
-
-    fn is_dir(&self, project: &Project, cx: &App) -> bool {
-        self.entry(project, cx).is_some_and(|e| e.is_dir())
-            || self.suffix.as_ref().is_some_and(|s| s.ends_with('/'))
-    }
-
-    fn relative_path(&self) -> String {
-        if let Some(path_match) = &self.path_match {
-            if let Some(suffix) = &self.suffix {
-                format!(
-                    "{}/{}",
-                    path_match.path.to_string_lossy(),
-                    suffix.trim_end_matches('/')
-                )
-            } else {
-                path_match.path.to_string_lossy().to_string()
-            }
-        } else if let Some(suffix) = &self.suffix {
-            suffix.trim_end_matches('/').to_string()
-        } else {
-            "".to_string()
-        }
-    }
-
-    fn project_path(&self, project: &Project, cx: &App) -> Option<ProjectPath> {
-        let worktree_id = if let Some(path_match) = &self.path_match {
-            WorktreeId::from_usize(path_match.worktree_id)
-        } else if let Some(worktree) = project.visible_worktrees(cx).find(|worktree| {
-            worktree
-                .read(cx)
-                .root_entry()
-                .is_some_and(|entry| entry.is_dir())
-        }) {
-            worktree.read(cx).id()
-        } else {
-            // todo(): we should find_or_create a workspace.
-            return None;
-        };
-
-        let path = PathBuf::from(self.relative_path());
-
-        Some(ProjectPath {
-            worktree_id,
-            path: Arc::from(path),
-        })
-    }
-
-    fn existing_prefix(&self, project: &Project, cx: &App) -> Option<PathBuf> {
-        let worktree = project.worktrees(cx).next()?.read(cx);
-        let mut prefix = PathBuf::new();
-        let parts = self.suffix.as_ref()?.split('/');
-        for part in parts {
-            if worktree.entry_for_path(prefix.join(&part)).is_none() {
-                return Some(prefix);
-            }
-            prefix = prefix.join(part);
-        }
-
-        None
-    }
-
-    fn styled_text(&self, project: &Project, window: &Window, cx: &App) -> StyledText {
-        let mut text = "./".to_string();
-        let mut highlights = Vec::new();
-        let mut offset = text.len();
-
-        let separator = '/';
-        let dir_indicator = "[…]";
-
-        if let Some(path_match) = &self.path_match {
-            text.push_str(&path_match.path.to_string_lossy());
-            let mut whole_path = PathBuf::from(path_match.path_prefix.to_string());
-            whole_path = whole_path.join(path_match.path.clone());
-            for (range, style) in highlight_ranges(
-                &whole_path.to_string_lossy(),
-                &path_match.positions,
-                gpui::HighlightStyle::color(Color::Accent.color(cx)),
-            ) {
-                highlights.push((range.start + offset..range.end + offset, style))
-            }
-            text.push(separator);
-            offset = text.len();
-
-            if let Some(suffix) = &self.suffix {
-                text.push_str(suffix);
-                let entry = self.entry(project, cx);
-                let color = if let Some(entry) = entry {
-                    if entry.is_dir() {
-                        Color::Accent
-                    } else {
-                        Color::Conflict
-                    }
-                } else {
-                    Color::Created
-                };
-                highlights.push((
-                    offset..offset + suffix.len(),
-                    HighlightStyle::color(color.color(cx)),
-                ));
-                offset += suffix.len();
-                if entry.is_some_and(|e| e.is_dir()) {
-                    text.push(separator);
-                    offset += separator.len_utf8();
-
-                    text.push_str(dir_indicator);
-                    highlights.push((
-                        offset..offset + dir_indicator.len(),
-                        HighlightStyle::color(Color::Muted.color(cx)),
-                    ));
-                }
-            } else {
-                text.push_str(dir_indicator);
-                highlights.push((
-                    offset..offset + dir_indicator.len(),
-                    HighlightStyle::color(Color::Muted.color(cx)),
-                ))
-            }
-        } else if let Some(suffix) = &self.suffix {
-            text.push_str(suffix);
-            let existing_prefix_len = self
-                .existing_prefix(project, cx)
-                .map(|prefix| prefix.to_string_lossy().len())
-                .unwrap_or(0);
-
-            if existing_prefix_len > 0 {
-                highlights.push((
-                    offset..offset + existing_prefix_len,
-                    HighlightStyle::color(Color::Accent.color(cx)),
-                ));
-            }
-            highlights.push((
-                offset + existing_prefix_len..offset + suffix.len(),
-                HighlightStyle::color(if self.entry(project, cx).is_some() {
-                    Color::Conflict.color(cx)
-                } else {
-                    Color::Created.color(cx)
-                }),
-            ));
-            offset += suffix.len();
-            if suffix.ends_with('/') {
-                text.push_str(dir_indicator);
-                highlights.push((
-                    offset..offset + dir_indicator.len(),
-                    HighlightStyle::color(Color::Muted.color(cx)),
-                ));
-            }
-        }
-
-        StyledText::new(text).with_default_highlights(&window.text_style().clone(), highlights)
-    }
-}
-
-pub struct NewPathDelegate {
-    project: Entity<Project>,
-    tx: Option<oneshot::Sender<Option<ProjectPath>>>,
-    selected_index: usize,
-    matches: Vec<Match>,
-    last_selected_dir: Option<String>,
-    cancel_flag: Arc<AtomicBool>,
-    should_dismiss: bool,
-}
-
-impl NewPathPrompt {
-    pub(crate) fn register(
-        workspace: &mut Workspace,
-        _window: Option<&mut Window>,
-        _cx: &mut Context<Workspace>,
-    ) {
-        workspace.set_prompt_for_new_path(Box::new(|workspace, window, cx| {
-            let (tx, rx) = futures::channel::oneshot::channel();
-            Self::prompt_for_new_path(workspace, tx, window, cx);
-            rx
-        }));
-    }
-
-    fn prompt_for_new_path(
-        workspace: &mut Workspace,
-        tx: oneshot::Sender<Option<ProjectPath>>,
-        window: &mut Window,
-        cx: &mut Context<Workspace>,
-    ) {
-        let project = workspace.project().clone();
-        workspace.toggle_modal(window, cx, |window, cx| {
-            let delegate = NewPathDelegate {
-                project,
-                tx: Some(tx),
-                selected_index: 0,
-                matches: vec![],
-                cancel_flag: Arc::new(AtomicBool::new(false)),
-                last_selected_dir: None,
-                should_dismiss: true,
-            };
-
-            Picker::uniform_list(delegate, window, cx).width(rems(34.))
-        });
-    }
-}
-
-impl PickerDelegate for NewPathDelegate {
-    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,
-        _: &mut Window,
-        cx: &mut Context<picker::Picker<Self>>,
-    ) {
-        self.selected_index = ix;
-        cx.notify();
-    }
-
-    fn update_matches(
-        &mut self,
-        query: String,
-        window: &mut Window,
-        cx: &mut Context<picker::Picker<Self>>,
-    ) -> gpui::Task<()> {
-        let query = query
-            .trim()
-            .trim_start_matches("./")
-            .trim_start_matches('/');
-
-        let (dir, suffix) = if let Some(index) = query.rfind('/') {
-            let suffix = if index + 1 < query.len() {
-                Some(query[index + 1..].to_string())
-            } else {
-                None
-            };
-            (query[0..index].to_string(), suffix)
-        } else {
-            (query.to_string(), None)
-        };
-
-        let worktrees = self
-            .project
-            .read(cx)
-            .visible_worktrees(cx)
-            .collect::<Vec<_>>();
-        let include_root_name = worktrees.len() > 1;
-        let candidate_sets = worktrees
-            .into_iter()
-            .map(|worktree| {
-                let worktree = worktree.read(cx);
-                PathMatchCandidateSet {
-                    snapshot: worktree.snapshot(),
-                    include_ignored: worktree
-                        .root_entry()
-                        .map_or(false, |entry| entry.is_ignored),
-                    include_root_name,
-                    candidates: project::Candidates::Directories,
-                }
-            })
-            .collect::<Vec<_>>();
-
-        self.cancel_flag.store(true, atomic::Ordering::Relaxed);
-        self.cancel_flag = Arc::new(AtomicBool::new(false));
-
-        let cancel_flag = self.cancel_flag.clone();
-        let query = query.to_string();
-        let prefix = dir.clone();
-        cx.spawn_in(window, async move |picker, cx| {
-            let matches = fuzzy::match_path_sets(
-                candidate_sets.as_slice(),
-                &dir,
-                None,
-                false,
-                100,
-                &cancel_flag,
-                cx.background_executor().clone(),
-            )
-            .await;
-            let did_cancel = cancel_flag.load(atomic::Ordering::Relaxed);
-            if did_cancel {
-                return;
-            }
-            picker
-                .update(cx, |picker, cx| {
-                    picker
-                        .delegate
-                        .set_search_matches(query, prefix, suffix, matches, cx)
-                })
-                .log_err();
-        })
-    }
-
-    fn confirm_completion(
-        &mut self,
-        _: String,
-        window: &mut Window,
-        cx: &mut Context<Picker<Self>>,
-    ) -> Option<String> {
-        self.confirm_update_query(window, cx)
-    }
-
-    fn confirm_update_query(
-        &mut self,
-        _: &mut Window,
-        cx: &mut Context<Picker<Self>>,
-    ) -> Option<String> {
-        let m = self.matches.get(self.selected_index)?;
-        if m.is_dir(self.project.read(cx), cx) {
-            let path = m.relative_path();
-            let result = format!("{}/", path);
-            self.last_selected_dir = Some(path);
-            Some(result)
-        } else {
-            None
-        }
-    }
-
-    fn confirm(&mut self, _: bool, window: &mut Window, cx: &mut Context<picker::Picker<Self>>) {
-        let Some(m) = self.matches.get(self.selected_index) else {
-            return;
-        };
-
-        let exists = m.entry(self.project.read(cx), cx).is_some();
-        if exists {
-            self.should_dismiss = false;
-            let answer = window.prompt(
-                gpui::PromptLevel::Critical,
-                &format!("{} already exists. Do you want to replace it?", m.relative_path()),
-                Some(
-                    "A file or folder with the same name already exists. Replacing it will overwrite its current contents.",
-                ),
-                &["Replace", "Cancel"],
-            cx);
-            let m = m.clone();
-            cx.spawn_in(window, async move |picker, cx| {
-                let answer = answer.await.ok();
-                picker
-                    .update(cx, |picker, cx| {
-                        picker.delegate.should_dismiss = true;
-                        if answer != Some(0) {
-                            return;
-                        }
-                        if let Some(path) = m.project_path(picker.delegate.project.read(cx), cx) {
-                            if let Some(tx) = picker.delegate.tx.take() {
-                                tx.send(Some(path)).ok();
-                            }
-                        }
-                        cx.emit(gpui::DismissEvent);
-                    })
-                    .ok();
-            })
-            .detach();
-            return;
-        }
-
-        if let Some(path) = m.project_path(self.project.read(cx), cx) {
-            if let Some(tx) = self.tx.take() {
-                tx.send(Some(path)).ok();
-            }
-        }
-        cx.emit(gpui::DismissEvent);
-    }
-
-    fn should_dismiss(&self) -> bool {
-        self.should_dismiss
-    }
-
-    fn dismissed(&mut self, _: &mut Window, cx: &mut Context<picker::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,
-        window: &mut Window,
-        cx: &mut Context<picker::Picker<Self>>,
-    ) -> Option<Self::ListItem> {
-        let m = self.matches.get(ix)?;
-
-        Some(
-            ListItem::new(ix)
-                .spacing(ListItemSpacing::Sparse)
-                .inset(true)
-                .toggle_state(selected)
-                .child(LabelLike::new().child(m.styled_text(self.project.read(cx), window, cx))),
-        )
-    }
-
-    fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
-        Some("Type a path...".into())
-    }
-
-    fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
-        Arc::from("[directory/]filename.ext")
-    }
-}
-
-impl NewPathDelegate {
-    fn set_search_matches(
-        &mut self,
-        query: String,
-        prefix: String,
-        suffix: Option<String>,
-        matches: Vec<PathMatch>,
-        cx: &mut Context<Picker<Self>>,
-    ) {
-        cx.notify();
-        if query.is_empty() {
-            self.matches = self
-                .project
-                .read(cx)
-                .worktrees(cx)
-                .flat_map(|worktree| {
-                    let worktree_id = worktree.read(cx).id();
-                    worktree
-                        .read(cx)
-                        .child_entries(Path::new(""))
-                        .filter_map(move |entry| {
-                            entry.is_dir().then(|| Match {
-                                path_match: Some(PathMatch {
-                                    score: 1.0,
-                                    positions: Default::default(),
-                                    worktree_id: worktree_id.to_usize(),
-                                    path: entry.path.clone(),
-                                    path_prefix: "".into(),
-                                    is_dir: entry.is_dir(),
-                                    distance_to_relative_ancestor: 0,
-                                }),
-                                suffix: None,
-                            })
-                        })
-                })
-                .collect();
-
-            return;
-        }
-
-        let mut directory_exists = false;
-
-        self.matches = matches
-            .into_iter()
-            .map(|m| {
-                if m.path.as_ref().to_string_lossy() == prefix {
-                    directory_exists = true
-                }
-                Match {
-                    path_match: Some(m),
-                    suffix: suffix.clone(),
-                }
-            })
-            .collect();
-
-        if !directory_exists {
-            if suffix.is_none()
-                || self
-                    .last_selected_dir
-                    .as_ref()
-                    .is_some_and(|d| query.starts_with(d))
-            {
-                self.matches.insert(
-                    0,
-                    Match {
-                        path_match: None,
-                        suffix: Some(query.clone()),
-                    },
-                )
-            } else {
-                self.matches.push(Match {
-                    path_match: None,
-                    suffix: Some(query.clone()),
-                })
-            }
-        }
-    }
-}

crates/file_finder/src/open_path_prompt.rs πŸ”—

@@ -2,6 +2,7 @@ use crate::file_finder_settings::FileFinderSettings;
 use file_icons::FileIcons;
 use futures::channel::oneshot;
 use fuzzy::{StringMatch, StringMatchCandidate};
+use gpui::{HighlightStyle, StyledText, Task};
 use picker::{Picker, PickerDelegate};
 use project::{DirectoryItem, DirectoryLister};
 use settings::Settings;
@@ -12,61 +13,136 @@ use std::{
         atomic::{self, AtomicBool},
     },
 };
-use ui::{Context, ListItem, Window};
+use ui::{Context, LabelLike, ListItem, Window};
 use ui::{HighlightedLabel, ListItemSpacing, prelude::*};
 use util::{maybe, paths::compare_paths};
 use workspace::Workspace;
 
 pub(crate) struct OpenPathPrompt;
 
+#[cfg(target_os = "windows")]
+const PROMPT_ROOT: &str = "C:\\";
+#[cfg(not(target_os = "windows"))]
+const PROMPT_ROOT: &str = "/";
+
+#[derive(Debug)]
 pub struct OpenPathDelegate {
     tx: Option<oneshot::Sender<Option<Vec<PathBuf>>>>,
     lister: DirectoryLister,
     selected_index: usize,
-    directory_state: Option<DirectoryState>,
-    matches: Vec<usize>,
+    directory_state: DirectoryState,
     string_matches: Vec<StringMatch>,
     cancel_flag: Arc<AtomicBool>,
     should_dismiss: bool,
+    replace_prompt: Task<()>,
 }
 
 impl OpenPathDelegate {
-    pub fn new(tx: oneshot::Sender<Option<Vec<PathBuf>>>, lister: DirectoryLister) -> Self {
+    pub fn new(
+        tx: oneshot::Sender<Option<Vec<PathBuf>>>,
+        lister: DirectoryLister,
+        creating_path: bool,
+    ) -> Self {
         Self {
             tx: Some(tx),
             lister,
             selected_index: 0,
-            directory_state: None,
-            matches: Vec::new(),
+            directory_state: DirectoryState::None {
+                create: creating_path,
+            },
             string_matches: Vec::new(),
             cancel_flag: Arc::new(AtomicBool::new(false)),
             should_dismiss: true,
+            replace_prompt: Task::ready(()),
+        }
+    }
+
+    fn get_entry(&self, selected_match_index: usize) -> Option<CandidateInfo> {
+        match &self.directory_state {
+            DirectoryState::List { entries, .. } => {
+                let id = self.string_matches.get(selected_match_index)?.candidate_id;
+                entries.iter().find(|entry| entry.path.id == id).cloned()
+            }
+            DirectoryState::Create {
+                user_input,
+                entries,
+                ..
+            } => {
+                let mut i = selected_match_index;
+                if let Some(user_input) = user_input {
+                    if !user_input.exists || !user_input.is_dir {
+                        if i == 0 {
+                            return Some(CandidateInfo {
+                                path: user_input.file.clone(),
+                                is_dir: false,
+                            });
+                        } else {
+                            i -= 1;
+                        }
+                    }
+                }
+                let id = self.string_matches.get(i)?.candidate_id;
+                entries.iter().find(|entry| entry.path.id == id).cloned()
+            }
+            DirectoryState::None { .. } => None,
         }
     }
 
     #[cfg(any(test, feature = "test-support"))]
     pub fn collect_match_candidates(&self) -> Vec<String> {
-        if let Some(state) = self.directory_state.as_ref() {
-            self.matches
+        match &self.directory_state {
+            DirectoryState::List { entries, .. } => self
+                .string_matches
                 .iter()
-                .filter_map(|&index| {
-                    state
-                        .match_candidates
-                        .get(index)
+                .filter_map(|string_match| {
+                    entries
+                        .iter()
+                        .find(|entry| entry.path.id == string_match.candidate_id)
                         .map(|candidate| candidate.path.string.clone())
                 })
-                .collect()
-        } else {
-            Vec::new()
+                .collect(),
+            DirectoryState::Create {
+                user_input,
+                entries,
+                ..
+            } => user_input
+                .into_iter()
+                .filter(|user_input| !user_input.exists || !user_input.is_dir)
+                .map(|user_input| user_input.file.string.clone())
+                .chain(self.string_matches.iter().filter_map(|string_match| {
+                    entries
+                        .iter()
+                        .find(|entry| entry.path.id == string_match.candidate_id)
+                        .map(|candidate| candidate.path.string.clone())
+                }))
+                .collect(),
+            DirectoryState::None { .. } => Vec::new(),
         }
     }
 }
 
 #[derive(Debug)]
-struct DirectoryState {
-    path: String,
-    match_candidates: Vec<CandidateInfo>,
-    error: Option<SharedString>,
+enum DirectoryState {
+    List {
+        parent_path: String,
+        entries: Vec<CandidateInfo>,
+        error: Option<SharedString>,
+    },
+    Create {
+        parent_path: String,
+        user_input: Option<UserInput>,
+        entries: Vec<CandidateInfo>,
+    },
+    None {
+        create: bool,
+    },
+}
+
+#[derive(Debug, Clone)]
+struct UserInput {
+    file: StringMatchCandidate,
+    exists: bool,
+    is_dir: bool,
 }
 
 #[derive(Debug, Clone)]
@@ -83,7 +159,19 @@ impl OpenPathPrompt {
     ) {
         workspace.set_prompt_for_open_path(Box::new(|workspace, lister, window, cx| {
             let (tx, rx) = futures::channel::oneshot::channel();
-            Self::prompt_for_open_path(workspace, lister, tx, window, cx);
+            Self::prompt_for_open_path(workspace, lister, false, tx, window, cx);
+            rx
+        }));
+    }
+
+    pub(crate) fn register_new_path(
+        workspace: &mut Workspace,
+        _window: Option<&mut Window>,
+        _: &mut Context<Workspace>,
+    ) {
+        workspace.set_prompt_for_new_path(Box::new(|workspace, lister, window, cx| {
+            let (tx, rx) = futures::channel::oneshot::channel();
+            Self::prompt_for_open_path(workspace, lister, true, tx, window, cx);
             rx
         }));
     }
@@ -91,13 +179,13 @@ impl OpenPathPrompt {
     fn prompt_for_open_path(
         workspace: &mut Workspace,
         lister: DirectoryLister,
+        creating_path: bool,
         tx: oneshot::Sender<Option<Vec<PathBuf>>>,
         window: &mut Window,
         cx: &mut Context<Workspace>,
     ) {
         workspace.toggle_modal(window, cx, |window, cx| {
-            let delegate = OpenPathDelegate::new(tx, lister.clone());
-
+            let delegate = OpenPathDelegate::new(tx, lister.clone(), creating_path);
             let picker = Picker::uniform_list(delegate, window, cx).width(rems(34.));
             let query = lister.default_query(cx);
             picker.set_query(query, window, cx);
@@ -110,7 +198,16 @@ impl PickerDelegate for OpenPathDelegate {
     type ListItem = ui::ListItem;
 
     fn match_count(&self) -> usize {
-        self.matches.len()
+        let user_input = if let DirectoryState::Create { user_input, .. } = &self.directory_state {
+            user_input
+                .as_ref()
+                .filter(|input| !input.exists || !input.is_dir)
+                .into_iter()
+                .count()
+        } else {
+            0
+        };
+        self.string_matches.len() + user_input
     }
 
     fn selected_index(&self) -> usize {
@@ -127,127 +224,196 @@ impl PickerDelegate for OpenPathDelegate {
         query: String,
         window: &mut Window,
         cx: &mut Context<Picker<Self>>,
-    ) -> gpui::Task<()> {
-        let lister = self.lister.clone();
-        let query_path = Path::new(&query);
-        let last_item = query_path
+    ) -> Task<()> {
+        let lister = &self.lister;
+        let last_item = Path::new(&query)
             .file_name()
             .unwrap_or_default()
-            .to_string_lossy()
-            .to_string();
-        let (mut dir, suffix) = if let Some(dir) = query.strip_suffix(&last_item) {
-            (dir.to_string(), last_item)
+            .to_string_lossy();
+        let (mut dir, suffix) = if let Some(dir) = query.strip_suffix(last_item.as_ref()) {
+            (dir.to_string(), last_item.into_owned())
         } else {
             (query, String::new())
         };
-
         if dir == "" {
-            #[cfg(not(target_os = "windows"))]
-            {
-                dir = "/".to_string();
-            }
-            #[cfg(target_os = "windows")]
-            {
-                dir = "C:\\".to_string();
-            }
+            dir = PROMPT_ROOT.to_string();
         }
 
-        let query = if self
-            .directory_state
-            .as_ref()
-            .map_or(false, |s| s.path == dir)
-        {
-            None
-        } else {
-            Some(lister.list_directory(dir.clone(), cx))
+        let query = match &self.directory_state {
+            DirectoryState::List { parent_path, .. } => {
+                if parent_path == &dir {
+                    None
+                } else {
+                    Some(lister.list_directory(dir.clone(), cx))
+                }
+            }
+            DirectoryState::Create {
+                parent_path,
+                user_input,
+                ..
+            } => {
+                if parent_path == &dir
+                    && user_input.as_ref().map(|input| &input.file.string) == Some(&suffix)
+                {
+                    None
+                } else {
+                    Some(lister.list_directory(dir.clone(), cx))
+                }
+            }
+            DirectoryState::None { .. } => Some(lister.list_directory(dir.clone(), cx)),
         };
-        self.cancel_flag.store(true, atomic::Ordering::Relaxed);
+        self.cancel_flag.store(true, atomic::Ordering::Release);
         self.cancel_flag = Arc::new(AtomicBool::new(false));
         let cancel_flag = self.cancel_flag.clone();
 
         cx.spawn_in(window, async move |this, cx| {
             if let Some(query) = query {
                 let paths = query.await;
-                if cancel_flag.load(atomic::Ordering::Relaxed) {
+                if cancel_flag.load(atomic::Ordering::Acquire) {
                     return;
                 }
 
-                this.update(cx, |this, _| {
-                    this.delegate.directory_state = Some(match paths {
-                        Ok(mut paths) => {
-                            if dir == "/" {
-                                paths.push(DirectoryItem {
-                                    is_dir: true,
-                                    path: Default::default(),
-                                });
-                            }
-
-                            paths.sort_by(|a, b| compare_paths((&a.path, true), (&b.path, true)));
-                            let match_candidates = paths
-                                .iter()
-                                .enumerate()
-                                .map(|(ix, item)| CandidateInfo {
-                                    path: StringMatchCandidate::new(
-                                        ix,
-                                        &item.path.to_string_lossy(),
-                                    ),
-                                    is_dir: item.is_dir,
-                                })
-                                .collect::<Vec<_>>();
-
-                            DirectoryState {
-                                match_candidates,
-                                path: dir,
-                                error: None,
-                            }
-                        }
-                        Err(err) => DirectoryState {
-                            match_candidates: vec![],
-                            path: dir,
-                            error: Some(err.to_string().into()),
-                        },
-                    });
-                })
-                .ok();
+                if this
+                    .update(cx, |this, _| {
+                        let new_state = match &this.delegate.directory_state {
+                            DirectoryState::None { create: false }
+                            | DirectoryState::List { .. } => match paths {
+                                Ok(paths) => DirectoryState::List {
+                                    entries: path_candidates(&dir, paths),
+                                    parent_path: dir.clone(),
+                                    error: None,
+                                },
+                                Err(e) => DirectoryState::List {
+                                    entries: Vec::new(),
+                                    parent_path: dir.clone(),
+                                    error: Some(SharedString::from(e.to_string())),
+                                },
+                            },
+                            DirectoryState::None { create: true }
+                            | DirectoryState::Create { .. } => match paths {
+                                Ok(paths) => {
+                                    let mut entries = path_candidates(&dir, paths);
+                                    let mut exists = false;
+                                    let mut is_dir = false;
+                                    let mut new_id = None;
+                                    entries.retain(|entry| {
+                                        new_id = new_id.max(Some(entry.path.id));
+                                        if entry.path.string == suffix {
+                                            exists = true;
+                                            is_dir = entry.is_dir;
+                                        }
+                                        !exists || is_dir
+                                    });
+
+                                    let new_id = new_id.map(|id| id + 1).unwrap_or(0);
+                                    let user_input = if suffix.is_empty() {
+                                        None
+                                    } else {
+                                        Some(UserInput {
+                                            file: StringMatchCandidate::new(new_id, &suffix),
+                                            exists,
+                                            is_dir,
+                                        })
+                                    };
+                                    DirectoryState::Create {
+                                        entries,
+                                        parent_path: dir.clone(),
+                                        user_input,
+                                    }
+                                }
+                                Err(_) => DirectoryState::Create {
+                                    entries: Vec::new(),
+                                    parent_path: dir.clone(),
+                                    user_input: Some(UserInput {
+                                        exists: false,
+                                        is_dir: false,
+                                        file: StringMatchCandidate::new(0, &suffix),
+                                    }),
+                                },
+                            },
+                        };
+                        this.delegate.directory_state = new_state;
+                    })
+                    .is_err()
+                {
+                    return;
+                }
             }
 
-            let match_candidates = this
-                .update(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;
+            let Ok(mut new_entries) =
+                this.update(cx, |this, _| match &this.delegate.directory_state {
+                    DirectoryState::List {
+                        entries,
+                        error: None,
+                        ..
+                    }
+                    | DirectoryState::Create { entries, .. } => entries.clone(),
+                    DirectoryState::List { error: Some(_), .. } | DirectoryState::None { .. } => {
+                        Vec::new()
                     }
-
-                    Some(directory_state.match_candidates.clone())
                 })
-                .unwrap_or(None);
-
-            let Some(mut match_candidates) = match_candidates else {
+            else {
                 return;
             };
 
             if !suffix.starts_with('.') {
-                match_candidates.retain(|m| !m.path.string.starts_with('.'));
+                new_entries.retain(|entry| !entry.path.string.starts_with('.'));
             }
-
-            if suffix == "" {
+            if suffix.is_empty() {
                 this.update(cx, |this, cx| {
-                    this.delegate.matches.clear();
-                    this.delegate.string_matches.clear();
-                    this.delegate
-                        .matches
-                        .extend(match_candidates.iter().map(|m| m.path.id));
-
+                    this.delegate.selected_index = 0;
+                    this.delegate.string_matches = new_entries
+                        .iter()
+                        .map(|m| StringMatch {
+                            candidate_id: m.path.id,
+                            score: 0.0,
+                            positions: Vec::new(),
+                            string: m.path.string.clone(),
+                        })
+                        .collect();
+                    this.delegate.directory_state =
+                        match &this.delegate.directory_state {
+                            DirectoryState::None { create: false }
+                            | DirectoryState::List { .. } => DirectoryState::List {
+                                parent_path: dir.clone(),
+                                entries: new_entries,
+                                error: None,
+                            },
+                            DirectoryState::None { create: true }
+                            | DirectoryState::Create { .. } => DirectoryState::Create {
+                                parent_path: dir.clone(),
+                                user_input: None,
+                                entries: new_entries,
+                            },
+                        };
                     cx.notify();
                 })
                 .ok();
                 return;
             }
 
-            let candidates = match_candidates.iter().map(|m| &m.path).collect::<Vec<_>>();
+            let Ok(is_create_state) =
+                this.update(cx, |this, _| match &this.delegate.directory_state {
+                    DirectoryState::Create { .. } => true,
+                    DirectoryState::List { .. } => false,
+                    DirectoryState::None { create } => *create,
+                })
+            else {
+                return;
+            };
+
+            let candidates = new_entries
+                .iter()
+                .filter_map(|entry| {
+                    if is_create_state && !entry.is_dir && Some(&suffix) == Some(&entry.path.string)
+                    {
+                        None
+                    } else {
+                        Some(&entry.path)
+                    }
+                })
+                .collect::<Vec<_>>();
+
             let matches = fuzzy::match_strings(
                 candidates.as_slice(),
                 &suffix,
@@ -257,27 +423,57 @@ impl PickerDelegate for OpenPathDelegate {
                 cx.background_executor().clone(),
             )
             .await;
-            if cancel_flag.load(atomic::Ordering::Relaxed) {
+            if cancel_flag.load(atomic::Ordering::Acquire) {
                 return;
             }
 
             this.update(cx, |this, cx| {
-                this.delegate.matches.clear();
+                this.delegate.selected_index = 0;
                 this.delegate.string_matches = matches.clone();
-                this.delegate
-                    .matches
-                    .extend(matches.into_iter().map(|m| m.candidate_id));
-                this.delegate.matches.sort_by_key(|m| {
+                this.delegate.string_matches.sort_by_key(|m| {
                     (
-                        this.delegate.directory_state.as_ref().and_then(|d| {
-                            d.match_candidates
-                                .get(*m)
-                                .map(|c| !c.path.string.starts_with(&suffix))
-                        }),
-                        *m,
+                        new_entries
+                            .iter()
+                            .find(|entry| entry.path.id == m.candidate_id)
+                            .map(|entry| &entry.path)
+                            .map(|candidate| !candidate.string.starts_with(&suffix)),
+                        m.candidate_id,
                     )
                 });
-                this.delegate.selected_index = 0;
+                this.delegate.directory_state = match &this.delegate.directory_state {
+                    DirectoryState::None { create: false } | DirectoryState::List { .. } => {
+                        DirectoryState::List {
+                            entries: new_entries,
+                            parent_path: dir.clone(),
+                            error: None,
+                        }
+                    }
+                    DirectoryState::None { create: true } => DirectoryState::Create {
+                        entries: new_entries,
+                        parent_path: dir.clone(),
+                        user_input: Some(UserInput {
+                            file: StringMatchCandidate::new(0, &suffix),
+                            exists: false,
+                            is_dir: false,
+                        }),
+                    },
+                    DirectoryState::Create { user_input, .. } => {
+                        let (new_id, exists, is_dir) = user_input
+                            .as_ref()
+                            .map(|input| (input.file.id, input.exists, input.is_dir))
+                            .unwrap_or_else(|| (0, false, false));
+                        DirectoryState::Create {
+                            entries: new_entries,
+                            parent_path: dir.clone(),
+                            user_input: Some(UserInput {
+                                file: StringMatchCandidate::new(new_id, &suffix),
+                                exists,
+                                is_dir,
+                            }),
+                        }
+                    }
+                };
+
                 cx.notify();
             })
             .ok();
@@ -290,49 +486,107 @@ impl PickerDelegate for OpenPathDelegate {
         _window: &mut Window,
         _: &mut Context<Picker<Self>>,
     ) -> Option<String> {
+        let candidate = self.get_entry(self.selected_index)?;
         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.path.string,
-                    if candidate.is_dir {
-                        MAIN_SEPARATOR_STR
-                    } else {
-                        ""
-                    }
-                ))
+                match &self.directory_state {
+                    DirectoryState::Create { parent_path, .. } => Some(format!(
+                        "{}{}{}",
+                        parent_path,
+                        candidate.path.string,
+                        if candidate.is_dir {
+                            MAIN_SEPARATOR_STR
+                        } else {
+                            ""
+                        }
+                    )),
+                    DirectoryState::List { parent_path, .. } => Some(format!(
+                        "{}{}{}",
+                        parent_path,
+                        candidate.path.string,
+                        if candidate.is_dir {
+                            MAIN_SEPARATOR_STR
+                        } else {
+                            ""
+                        }
+                    )),
+                    DirectoryState::None { .. } => return None,
+                }
             })
             .unwrap_or(query),
         )
     }
 
-    fn confirm(&mut self, _: bool, _: &mut Window, cx: &mut Context<Picker<Self>>) {
-        let Some(m) = self.matches.get(self.selected_index) else {
+    fn confirm(&mut self, _: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
+        let Some(candidate) = self.get_entry(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 = if directory_state.path == "/" && candidate.path.string.is_empty() {
-            PathBuf::from("/")
-        } else {
-            Path::new(
-                self.lister
-                    .resolve_tilde(&directory_state.path, cx)
-                    .as_ref(),
-            )
-            .join(&candidate.path.string)
-        };
-        if let Some(tx) = self.tx.take() {
-            tx.send(Some(vec![result])).ok();
+
+        match &self.directory_state {
+            DirectoryState::None { .. } => return,
+            DirectoryState::List { parent_path, .. } => {
+                let confirmed_path =
+                    if parent_path == PROMPT_ROOT && candidate.path.string.is_empty() {
+                        PathBuf::from(PROMPT_ROOT)
+                    } else {
+                        Path::new(self.lister.resolve_tilde(parent_path, cx).as_ref())
+                            .join(&candidate.path.string)
+                    };
+                if let Some(tx) = self.tx.take() {
+                    tx.send(Some(vec![confirmed_path])).ok();
+                }
+            }
+            DirectoryState::Create {
+                parent_path,
+                user_input,
+                ..
+            } => match user_input {
+                None => return,
+                Some(user_input) => {
+                    if user_input.is_dir {
+                        return;
+                    }
+                    let prompted_path =
+                        if parent_path == PROMPT_ROOT && user_input.file.string.is_empty() {
+                            PathBuf::from(PROMPT_ROOT)
+                        } else {
+                            Path::new(self.lister.resolve_tilde(parent_path, cx).as_ref())
+                                .join(&user_input.file.string)
+                        };
+                    if user_input.exists {
+                        self.should_dismiss = false;
+                        let answer = window.prompt(
+                            gpui::PromptLevel::Critical,
+                            &format!("{prompted_path:?} already exists. Do you want to replace it?"),
+                            Some(
+                                "A file or folder with the same name already exists. Replacing it will overwrite its current contents.",
+                            ),
+                            &["Replace", "Cancel"],
+                            cx
+                        );
+                        self.replace_prompt = cx.spawn_in(window, async move |picker, cx| {
+                            let answer = answer.await.ok();
+                            picker
+                                .update(cx, |picker, cx| {
+                                    picker.delegate.should_dismiss = true;
+                                    if answer != Some(0) {
+                                        return;
+                                    }
+                                    if let Some(tx) = picker.delegate.tx.take() {
+                                        tx.send(Some(vec![prompted_path])).ok();
+                                    }
+                                    cx.emit(gpui::DismissEvent);
+                                })
+                                .ok();
+                        });
+                        return;
+                    } else if let Some(tx) = self.tx.take() {
+                        tx.send(Some(vec![prompted_path])).ok();
+                    }
+                }
+            },
         }
+
         cx.emit(gpui::DismissEvent);
     }
 
@@ -351,19 +605,30 @@ impl PickerDelegate for OpenPathDelegate {
         &self,
         ix: usize,
         selected: bool,
-        _window: &mut Window,
+        window: &mut Window,
         cx: &mut Context<Picker<Self>>,
     ) -> Option<Self::ListItem> {
         let settings = FileFinderSettings::get_global(cx);
-        let m = self.matches.get(ix)?;
-        let directory_state = self.directory_state.as_ref()?;
-        let candidate = directory_state.match_candidates.get(*m)?;
-        let highlight_positions = self
-            .string_matches
-            .iter()
-            .find(|string_match| string_match.candidate_id == *m)
-            .map(|string_match| string_match.positions.clone())
-            .unwrap_or_default();
+        let candidate = self.get_entry(ix)?;
+        let match_positions = match &self.directory_state {
+            DirectoryState::List { .. } => self.string_matches.get(ix)?.positions.clone(),
+            DirectoryState::Create { user_input, .. } => {
+                if let Some(user_input) = user_input {
+                    if !user_input.exists || !user_input.is_dir {
+                        if ix == 0 {
+                            Vec::new()
+                        } else {
+                            self.string_matches.get(ix - 1)?.positions.clone()
+                        }
+                    } else {
+                        self.string_matches.get(ix)?.positions.clone()
+                    }
+                } else {
+                    self.string_matches.get(ix)?.positions.clone()
+                }
+            }
+            DirectoryState::None { .. } => Vec::new(),
+        };
 
         let file_icon = maybe!({
             if !settings.file_icons {
@@ -378,34 +643,128 @@ impl PickerDelegate for OpenPathDelegate {
             Some(Icon::from_path(icon).color(Color::Muted))
         });
 
-        Some(
-            ListItem::new(ix)
-                .spacing(ListItemSpacing::Sparse)
-                .start_slot::<Icon>(file_icon)
-                .inset(true)
-                .toggle_state(selected)
-                .child(HighlightedLabel::new(
-                    if directory_state.path == "/" {
-                        format!("/{}", candidate.path.string)
-                    } else {
-                        candidate.path.string.clone()
-                    },
-                    highlight_positions,
-                )),
-        )
+        match &self.directory_state {
+            DirectoryState::List { parent_path, .. } => Some(
+                ListItem::new(ix)
+                    .spacing(ListItemSpacing::Sparse)
+                    .start_slot::<Icon>(file_icon)
+                    .inset(true)
+                    .toggle_state(selected)
+                    .child(HighlightedLabel::new(
+                        if parent_path == PROMPT_ROOT {
+                            format!("{}{}", PROMPT_ROOT, candidate.path.string)
+                        } else {
+                            candidate.path.string.clone()
+                        },
+                        match_positions,
+                    )),
+            ),
+            DirectoryState::Create {
+                parent_path,
+                user_input,
+                ..
+            } => {
+                let (label, delta) = if parent_path == PROMPT_ROOT {
+                    (
+                        format!("{}{}", PROMPT_ROOT, candidate.path.string),
+                        PROMPT_ROOT.len(),
+                    )
+                } else {
+                    (candidate.path.string.clone(), 0)
+                };
+                let label_len = label.len();
+
+                let label_with_highlights = match user_input {
+                    Some(user_input) => {
+                        if user_input.file.string == candidate.path.string {
+                            if user_input.exists {
+                                let label = if user_input.is_dir {
+                                    label
+                                } else {
+                                    format!("{label} (replace)")
+                                };
+                                StyledText::new(label)
+                                    .with_default_highlights(
+                                        &window.text_style().clone(),
+                                        vec![(
+                                            delta..delta + label_len,
+                                            HighlightStyle::color(Color::Conflict.color(cx)),
+                                        )],
+                                    )
+                                    .into_any_element()
+                            } else {
+                                StyledText::new(format!("{label} (create)"))
+                                    .with_default_highlights(
+                                        &window.text_style().clone(),
+                                        vec![(
+                                            delta..delta + label_len,
+                                            HighlightStyle::color(Color::Created.color(cx)),
+                                        )],
+                                    )
+                                    .into_any_element()
+                            }
+                        } else {
+                            let mut highlight_positions = match_positions;
+                            highlight_positions.iter_mut().for_each(|position| {
+                                *position += delta;
+                            });
+                            HighlightedLabel::new(label, highlight_positions).into_any_element()
+                        }
+                    }
+                    None => {
+                        let mut highlight_positions = match_positions;
+                        highlight_positions.iter_mut().for_each(|position| {
+                            *position += delta;
+                        });
+                        HighlightedLabel::new(label, highlight_positions).into_any_element()
+                    }
+                };
+
+                Some(
+                    ListItem::new(ix)
+                        .spacing(ListItemSpacing::Sparse)
+                        .start_slot::<Icon>(file_icon)
+                        .inset(true)
+                        .toggle_state(selected)
+                        .child(LabelLike::new().child(label_with_highlights)),
+                )
+            }
+            DirectoryState::None { .. } => return None,
+        }
     }
 
     fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
-        let text = if let Some(error) = self.directory_state.as_ref().and_then(|s| s.error.clone())
-        {
-            error
-        } else {
-            "No such file or directory".into()
-        };
-        Some(text)
+        Some(match &self.directory_state {
+            DirectoryState::Create { .. } => SharedString::from("Type a path…"),
+            DirectoryState::List {
+                error: Some(error), ..
+            } => error.clone(),
+            DirectoryState::List { .. } | DirectoryState::None { .. } => {
+                SharedString::from("No such file or directory")
+            }
+        })
     }
 
     fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
         Arc::from(format!("[directory{MAIN_SEPARATOR_STR}]filename.ext"))
     }
 }
+
+fn path_candidates(parent_path: &String, mut children: Vec<DirectoryItem>) -> Vec<CandidateInfo> {
+    if *parent_path == PROMPT_ROOT {
+        children.push(DirectoryItem {
+            is_dir: true,
+            path: PathBuf::default(),
+        });
+    }
+
+    children.sort_by(|a, b| compare_paths((&a.path, true), (&b.path, true)));
+    children
+        .iter()
+        .enumerate()
+        .map(|(ix, item)| CandidateInfo {
+            path: StringMatchCandidate::new(ix, &item.path.to_string_lossy()),
+            is_dir: item.is_dir,
+        })
+        .collect()
+}

crates/file_finder/src/open_path_prompt_tests.rs πŸ”—

@@ -37,7 +37,7 @@ async fn test_open_path_prompt(cx: &mut TestAppContext) {
 
     let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
 
-    let (picker, cx) = build_open_path_prompt(project, cx);
+    let (picker, cx) = build_open_path_prompt(project, false, cx);
 
     let query = path!("/root");
     insert_query(query, &picker, cx).await;
@@ -111,7 +111,7 @@ async fn test_open_path_prompt_completion(cx: &mut TestAppContext) {
 
     let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
 
-    let (picker, cx) = build_open_path_prompt(project, cx);
+    let (picker, cx) = build_open_path_prompt(project, false, cx);
 
     // Confirm completion for the query "/root", since it's a directory, it should add a trailing slash.
     let query = path!("/root");
@@ -204,7 +204,7 @@ async fn test_open_path_prompt_on_windows(cx: &mut TestAppContext) {
 
     let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
 
-    let (picker, cx) = build_open_path_prompt(project, cx);
+    let (picker, cx) = build_open_path_prompt(project, false, cx);
 
     // Support both forward and backward slashes.
     let query = "C:/root/";
@@ -251,6 +251,54 @@ async fn test_open_path_prompt_on_windows(cx: &mut TestAppContext) {
     );
 }
 
+#[gpui::test]
+async fn test_new_path_prompt(cx: &mut TestAppContext) {
+    let app_state = init_test(cx);
+    app_state
+        .fs
+        .as_fake()
+        .insert_tree(
+            path!("/root"),
+            json!({
+                "a1": "A1",
+                "a2": "A2",
+                "a3": "A3",
+                "dir1": {},
+                "dir2": {
+                    "c": "C",
+                    "d1": "D1",
+                    "d2": "D2",
+                    "d3": "D3",
+                    "dir3": {},
+                    "dir4": {}
+                }
+            }),
+        )
+        .await;
+
+    let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
+
+    let (picker, cx) = build_open_path_prompt(project, true, cx);
+
+    insert_query(path!("/root"), &picker, cx).await;
+    assert_eq!(collect_match_candidates(&picker, cx), vec!["root"]);
+
+    insert_query(path!("/root/d"), &picker, cx).await;
+    assert_eq!(
+        collect_match_candidates(&picker, cx),
+        vec!["d", "dir1", "dir2"]
+    );
+
+    insert_query(path!("/root/dir1"), &picker, cx).await;
+    assert_eq!(collect_match_candidates(&picker, cx), vec!["dir1"]);
+
+    insert_query(path!("/root/dir12"), &picker, cx).await;
+    assert_eq!(collect_match_candidates(&picker, cx), vec!["dir12"]);
+
+    insert_query(path!("/root/dir1"), &picker, cx).await;
+    assert_eq!(collect_match_candidates(&picker, cx), vec!["dir1"]);
+}
+
 fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
     cx.update(|cx| {
         let state = AppState::test(cx);
@@ -266,11 +314,12 @@ fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
 
 fn build_open_path_prompt(
     project: Entity<Project>,
+    creating_path: bool,
     cx: &mut TestAppContext,
 ) -> (Entity<Picker<OpenPathDelegate>>, &mut VisualTestContext) {
     let (tx, _) = futures::channel::oneshot::channel();
     let lister = project::DirectoryLister::Project(project.clone());
-    let delegate = OpenPathDelegate::new(tx, lister.clone());
+    let delegate = OpenPathDelegate::new(tx, lister.clone(), creating_path);
 
     let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
     (

crates/project/src/project.rs πŸ”—

@@ -770,13 +770,26 @@ pub struct DirectoryItem {
 #[derive(Clone)]
 pub enum DirectoryLister {
     Project(Entity<Project>),
-    Local(Arc<dyn Fs>),
+    Local(Entity<Project>, Arc<dyn Fs>),
+}
+
+impl std::fmt::Debug for DirectoryLister {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            DirectoryLister::Project(project) => {
+                write!(f, "DirectoryLister::Project({project:?})")
+            }
+            DirectoryLister::Local(project, _) => {
+                write!(f, "DirectoryLister::Local({project:?})")
+            }
+        }
+    }
 }
 
 impl DirectoryLister {
     pub fn is_local(&self, cx: &App) -> bool {
         match self {
-            DirectoryLister::Local(_) => true,
+            DirectoryLister::Local(..) => true,
             DirectoryLister::Project(project) => project.read(cx).is_local(),
         }
     }
@@ -790,12 +803,28 @@ impl DirectoryLister {
     }
 
     pub fn default_query(&self, cx: &mut App) -> String {
-        if let DirectoryLister::Project(project) = self {
-            if let Some(worktree) = project.read(cx).visible_worktrees(cx).next() {
-                return worktree.read(cx).abs_path().to_string_lossy().to_string();
+        let separator = std::path::MAIN_SEPARATOR_STR;
+        match self {
+            DirectoryLister::Project(project) => project,
+            DirectoryLister::Local(project, _) => project,
+        }
+        .read(cx)
+        .visible_worktrees(cx)
+        .next()
+        .map(|worktree| worktree.read(cx).abs_path())
+        .map(|dir| dir.to_string_lossy().to_string())
+        .or_else(|| std::env::home_dir().map(|dir| dir.to_string_lossy().to_string()))
+        .map(|mut s| {
+            s.push_str(separator);
+            s
+        })
+        .unwrap_or_else(|| {
+            if cfg!(target_os = "windows") {
+                format!("C:{separator}")
+            } else {
+                format!("~{separator}")
             }
-        };
-        format!("~{}", std::path::MAIN_SEPARATOR_STR)
+        })
     }
 
     pub fn list_directory(&self, path: String, cx: &mut App) -> Task<Result<Vec<DirectoryItem>>> {
@@ -803,7 +832,7 @@ impl DirectoryLister {
             DirectoryLister::Project(project) => {
                 project.update(cx, |project, cx| project.list_directory(path, cx))
             }
-            DirectoryLister::Local(fs) => {
+            DirectoryLister::Local(_, fs) => {
                 let fs = fs.clone();
                 cx.background_spawn(async move {
                     let mut results = vec![];
@@ -4049,7 +4078,7 @@ impl Project {
         cx: &mut Context<Self>,
     ) -> Task<Result<Vec<DirectoryItem>>> {
         if self.is_local() {
-            DirectoryLister::Local(self.fs.clone()).list_directory(query, cx)
+            DirectoryLister::Local(cx.entity(), self.fs.clone()).list_directory(query, cx)
         } else if let Some(session) = self.ssh_client.as_ref() {
             let path_buf = PathBuf::from(query);
             let request = proto::ListRemoteDirectory {

crates/recent_projects/src/remote_servers.rs πŸ”—

@@ -147,7 +147,7 @@ impl ProjectPicker {
     ) -> Entity<Self> {
         let (tx, rx) = oneshot::channel();
         let lister = project::DirectoryLister::Project(project.clone());
-        let delegate = file_finder::OpenPathDelegate::new(tx, lister);
+        let delegate = file_finder::OpenPathDelegate::new(tx, lister, false);
 
         let picker = cx.new(|cx| {
             let picker = Picker::uniform_list(delegate, window, cx)

crates/workspace/src/pane.rs πŸ”—

@@ -25,7 +25,7 @@ use gpui::{
 use itertools::Itertools;
 use language::DiagnosticSeverity;
 use parking_lot::Mutex;
-use project::{Project, ProjectEntryId, ProjectPath, WorktreeId};
+use project::{DirectoryLister, Project, ProjectEntryId, ProjectPath, WorktreeId};
 use schemars::JsonSchema;
 use serde::Deserialize;
 use settings::{Settings, SettingsStore};
@@ -1921,24 +1921,56 @@ impl Pane {
                 })?
                 .await?;
             } else if can_save_as && is_singleton {
-                let abs_path = pane.update_in(cx, |pane, window, cx| {
+                let new_path = pane.update_in(cx, |pane, window, cx| {
                     pane.activate_item(item_ix, true, true, window, cx);
                     pane.workspace.update(cx, |workspace, cx| {
-                        workspace.prompt_for_new_path(window, cx)
+                        let lister = if workspace.project().read(cx).is_local() {
+                            DirectoryLister::Local(
+                                workspace.project().clone(),
+                                workspace.app_state().fs.clone(),
+                            )
+                        } else {
+                            DirectoryLister::Project(workspace.project().clone())
+                        };
+                        workspace.prompt_for_new_path(lister, window, cx)
                     })
                 })??;
-                if let Some(abs_path) = abs_path.await.ok().flatten() {
+                let Some(new_path) = new_path.await.ok().flatten().into_iter().flatten().next()
+                else {
+                    return Ok(false);
+                };
+
+                let project_path = pane
+                    .update(cx, |pane, cx| {
+                        pane.project
+                            .update(cx, |project, cx| {
+                                project.find_or_create_worktree(new_path, true, cx)
+                            })
+                            .ok()
+                    })
+                    .ok()
+                    .flatten();
+                let save_task = if let Some(project_path) = project_path {
+                    let (worktree, path) = project_path.await?;
+                    let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id())?;
+                    let new_path = ProjectPath {
+                        worktree_id,
+                        path: path.into(),
+                    };
+
                     pane.update_in(cx, |pane, window, cx| {
-                        if let Some(item) = pane.item_for_path(abs_path.clone(), cx) {
+                        if let Some(item) = pane.item_for_path(new_path.clone(), cx) {
                             pane.remove_item(item.item_id(), false, false, window, cx);
                         }
 
-                        item.save_as(project, abs_path, window, cx)
+                        item.save_as(project, new_path, window, cx)
                     })?
-                    .await?;
                 } else {
                     return Ok(false);
-                }
+                };
+
+                save_task.await?;
+                return Ok(true);
             }
         }
 

crates/workspace/src/workspace.rs πŸ”—

@@ -899,9 +899,10 @@ pub enum OpenVisible {
 type PromptForNewPath = Box<
     dyn Fn(
         &mut Workspace,
+        DirectoryLister,
         &mut Window,
         &mut Context<Workspace>,
-    ) -> oneshot::Receiver<Option<ProjectPath>>,
+    ) -> oneshot::Receiver<Option<Vec<PathBuf>>>,
 >;
 
 type PromptForOpenPath = Box<
@@ -1874,25 +1875,25 @@ impl Workspace {
             let (tx, rx) = oneshot::channel();
             let abs_path = cx.prompt_for_paths(path_prompt_options);
 
-            cx.spawn_in(window, async move |this, cx| {
+            cx.spawn_in(window, async move |workspace, cx| {
                 let Ok(result) = abs_path.await else {
                     return Ok(());
                 };
 
                 match result {
                     Ok(result) => {
-                        tx.send(result).log_err();
+                        tx.send(result).ok();
                     }
                     Err(err) => {
-                        let rx = this.update_in(cx, |this, window, cx| {
-                            this.show_portal_error(err.to_string(), cx);
-                            let prompt = this.on_prompt_for_open_path.take().unwrap();
-                            let rx = prompt(this, lister, window, cx);
-                            this.on_prompt_for_open_path = Some(prompt);
+                        let rx = workspace.update_in(cx, |workspace, window, cx| {
+                            workspace.show_portal_error(err.to_string(), cx);
+                            let prompt = workspace.on_prompt_for_open_path.take().unwrap();
+                            let rx = prompt(workspace, lister, window, cx);
+                            workspace.on_prompt_for_open_path = Some(prompt);
                             rx
                         })?;
                         if let Ok(path) = rx.await {
-                            tx.send(path).log_err();
+                            tx.send(path).ok();
                         }
                     }
                 };
@@ -1906,77 +1907,58 @@ impl Workspace {
 
     pub fn prompt_for_new_path(
         &mut self,
+        lister: DirectoryLister,
         window: &mut Window,
         cx: &mut Context<Self>,
-    ) -> oneshot::Receiver<Option<ProjectPath>> {
-        if (self.project.read(cx).is_via_collab() || self.project.read(cx).is_via_ssh())
+    ) -> oneshot::Receiver<Option<Vec<PathBuf>>> {
+        if self.project.read(cx).is_via_collab()
+            || self.project.read(cx).is_via_ssh()
             || !WorkspaceSettings::get_global(cx).use_system_path_prompts
         {
             let prompt = self.on_prompt_for_new_path.take().unwrap();
-            let rx = prompt(self, window, cx);
+            let rx = prompt(self, lister, window, cx);
             self.on_prompt_for_new_path = Some(prompt);
             return rx;
         }
 
         let (tx, rx) = oneshot::channel();
-        cx.spawn_in(window, async move |this, cx| {
-            let abs_path = this.update(cx, |this, cx| {
-                let mut relative_to = this
+        cx.spawn_in(window, async move |workspace, cx| {
+            let abs_path = workspace.update(cx, |workspace, cx| {
+                let relative_to = workspace
                     .most_recent_active_path(cx)
-                    .and_then(|p| p.parent().map(|p| p.to_path_buf()));
-                if relative_to.is_none() {
-                    let project = this.project.read(cx);
-                    relative_to = project
-                        .visible_worktrees(cx)
-                        .filter_map(|worktree| {
+                    .and_then(|p| p.parent().map(|p| p.to_path_buf()))
+                    .or_else(|| {
+                        let project = workspace.project.read(cx);
+                        project.visible_worktrees(cx).find_map(|worktree| {
                             Some(worktree.read(cx).as_local()?.abs_path().to_path_buf())
                         })
-                        .next()
-                };
-
-                cx.prompt_for_new_path(&relative_to.unwrap_or_else(|| PathBuf::from("")))
+                    })
+                    .or_else(std::env::home_dir)
+                    .unwrap_or_else(|| PathBuf::from(""));
+                cx.prompt_for_new_path(&relative_to)
             })?;
             let abs_path = match abs_path.await? {
                 Ok(path) => path,
                 Err(err) => {
-                    let rx = this.update_in(cx, |this, window, cx| {
-                        this.show_portal_error(err.to_string(), cx);
+                    let rx = workspace.update_in(cx, |workspace, window, cx| {
+                        workspace.show_portal_error(err.to_string(), cx);
 
-                        let prompt = this.on_prompt_for_new_path.take().unwrap();
-                        let rx = prompt(this, window, cx);
-                        this.on_prompt_for_new_path = Some(prompt);
+                        let prompt = workspace.on_prompt_for_new_path.take().unwrap();
+                        let rx = prompt(workspace, lister, window, cx);
+                        workspace.on_prompt_for_new_path = Some(prompt);
                         rx
                     })?;
                     if let Ok(path) = rx.await {
-                        tx.send(path).log_err();
+                        tx.send(path).ok();
                     }
                     return anyhow::Ok(());
                 }
             };
 
-            let project_path = abs_path.and_then(|abs_path| {
-                this.update(cx, |this, cx| {
-                    this.project.update(cx, |project, cx| {
-                        project.find_or_create_worktree(abs_path, true, cx)
-                    })
-                })
-                .ok()
-            });
-
-            if let Some(project_path) = project_path {
-                let (worktree, path) = project_path.await?;
-                let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id())?;
-                tx.send(Some(ProjectPath {
-                    worktree_id,
-                    path: path.into(),
-                }))
-                .ok();
-            } else {
-                tx.send(None).ok();
-            }
+            tx.send(abs_path.map(|path| vec![path])).ok();
             anyhow::Ok(())
         })
-        .detach_and_log_err(cx);
+        .detach();
 
         rx
     }

crates/zed/src/zed.rs πŸ”—

@@ -503,7 +503,10 @@ fn register_actions(
                     directories: true,
                     multiple: true,
                 },
-                DirectoryLister::Local(workspace.app_state().fs.clone()),
+                DirectoryLister::Local(
+                    workspace.project().clone(),
+                    workspace.app_state().fs.clone(),
+                ),
                 window,
                 cx,
             );