Improve Zed prompts for file path selection (#32014)

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>
This commit is contained in:
Kirill Bulatov 2025-06-03 23:35:25 +03:00 committed by GitHub
parent 8c46a4f594
commit 4aabba6cf6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 721 additions and 792 deletions

View file

@ -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 {

View file

@ -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()),
})
}
}
}
}

View file

@ -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(),
});
}
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
});
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();
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()
}

View file

@ -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));
(