From 4aabba6cf620afd6b2ff4dc45f3ddf3a96b8123f Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Tue, 3 Jun 2025 23:35:25 +0300 Subject: [PATCH] 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 --- 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 | 733 +++++++++++++----- .../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, 721 insertions(+), 792 deletions(-) delete mode 100644 crates/file_finder/src/new_path_prompt.rs diff --git a/crates/extensions_ui/src/extensions_ui.rs b/crates/extensions_ui/src/extensions_ui.rs index 72cf23a3b6..72547fbe75 100644 --- a/crates/extensions_ui/src/extensions_ui.rs +++ b/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, ); diff --git a/crates/file_finder/src/file_finder.rs b/crates/file_finder/src/file_finder.rs index 05780bffa6..1329f9073f 100644 --- a/crates/file_finder/src/file_finder.rs +++ b/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 { diff --git a/crates/file_finder/src/new_path_prompt.rs b/crates/file_finder/src/new_path_prompt.rs deleted file mode 100644 index 69b473e146..0000000000 --- a/crates/file_finder/src/new_path_prompt.rs +++ /dev/null @@ -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, - suffix: Option, -} - -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 { - 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 { - 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, - tx: Option>>, - selected_index: usize, - matches: Vec, - last_selected_dir: Option, - cancel_flag: Arc, - should_dismiss: bool, -} - -impl NewPathPrompt { - pub(crate) fn register( - workspace: &mut Workspace, - _window: Option<&mut Window>, - _cx: &mut Context, - ) { - 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>, - window: &mut Window, - cx: &mut Context, - ) { - 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>, - ) { - self.selected_index = ix; - cx.notify(); - } - - fn update_matches( - &mut self, - query: String, - window: &mut Window, - cx: &mut Context>, - ) -> 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::>(); - 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::>(); - - 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>, - ) -> Option { - self.confirm_update_query(window, cx) - } - - fn confirm_update_query( - &mut self, - _: &mut Window, - cx: &mut Context>, - ) -> Option { - 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>) { - 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>) { - 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>, - ) -> Option { - 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 { - Some("Type a path...".into()) - } - - fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { - Arc::from("[directory/]filename.ext") - } -} - -impl NewPathDelegate { - fn set_search_matches( - &mut self, - query: String, - prefix: String, - suffix: Option, - matches: Vec, - cx: &mut Context>, - ) { - 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()), - }) - } - } - } -} diff --git a/crates/file_finder/src/open_path_prompt.rs b/crates/file_finder/src/open_path_prompt.rs index 20bbc40cdb..71b345a536 100644 --- a/crates/file_finder/src/open_path_prompt.rs +++ b/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>>>, lister: DirectoryLister, selected_index: usize, - directory_state: Option, - matches: Vec, + directory_state: DirectoryState, string_matches: Vec, cancel_flag: Arc, should_dismiss: bool, + replace_prompt: Task<()>, } impl OpenPathDelegate { - pub fn new(tx: oneshot::Sender>>, lister: DirectoryLister) -> Self { + pub fn new( + tx: oneshot::Sender>>, + 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 { + 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 { - 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, - error: Option, +enum DirectoryState { + List { + parent_path: String, + entries: Vec, + error: Option, + }, + Create { + parent_path: String, + user_input: Option, + entries: Vec, + }, + 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.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>>, window: &mut Window, cx: &mut Context, ) { 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>, - ) -> 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::>(); - - 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::>(); + 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::>(); + 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>, ) -> Option { + 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>) { - let Some(m) = self.matches.get(self.selected_index) else { + fn confirm(&mut self, _: bool, window: &mut Window, cx: &mut Context>) { + 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>, ) -> Option { 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::(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::(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::(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 { - 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 { Arc::from(format!("[directory{MAIN_SEPARATOR_STR}]filename.ext")) } } + +fn path_candidates(parent_path: &String, mut children: Vec) -> Vec { + 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() +} diff --git a/crates/file_finder/src/open_path_prompt_tests.rs b/crates/file_finder/src/open_path_prompt_tests.rs index 1303f3e75a..0acf2a517d 100644 --- a/crates/file_finder/src/open_path_prompt_tests.rs +++ b/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 { cx.update(|cx| { let state = AppState::test(cx); @@ -266,11 +314,12 @@ fn init_test(cx: &mut TestAppContext) -> Arc { fn build_open_path_prompt( project: Entity, + creating_path: bool, cx: &mut TestAppContext, ) -> (Entity>, &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)); ( diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 5f45846227..fe9167dfaa 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -770,13 +770,26 @@ pub struct DirectoryItem { #[derive(Clone)] pub enum DirectoryLister { Project(Entity), - Local(Arc), + Local(Entity, Arc), +} + +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>> { @@ -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, ) -> Task>> { 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 { diff --git a/crates/recent_projects/src/remote_servers.rs b/crates/recent_projects/src/remote_servers.rs index c1a731ee13..f90db17fa8 100644 --- a/crates/recent_projects/src/remote_servers.rs +++ b/crates/recent_projects/src/remote_servers.rs @@ -147,7 +147,7 @@ impl ProjectPicker { ) -> Entity { 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) diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index 6031109abd..23d9f2cbf0 100644 --- a/crates/workspace/src/pane.rs +++ b/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); } } diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 8dd4253ed8..86a6a3dadb 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -899,9 +899,10 @@ pub enum OpenVisible { type PromptForNewPath = Box< dyn Fn( &mut Workspace, + DirectoryLister, &mut Window, &mut Context, - ) -> oneshot::Receiver>, + ) -> oneshot::Receiver>>, >; 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, - ) -> oneshot::Receiver> { - if (self.project.read(cx).is_via_collab() || self.project.read(cx).is_via_ssh()) + ) -> oneshot::Receiver>> { + 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 } diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 659ba06067..92b11507b3 100644 --- a/crates/zed/src/zed.rs +++ b/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, );