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:
parent
8c46a4f594
commit
4aabba6cf6
10 changed files with 721 additions and 792 deletions
|
@ -101,7 +101,10 @@ pub fn init(cx: &mut App) {
|
||||||
directories: true,
|
directories: true,
|
||||||
multiple: false,
|
multiple: false,
|
||||||
},
|
},
|
||||||
DirectoryLister::Local(workspace.app_state().fs.clone()),
|
DirectoryLister::Local(
|
||||||
|
workspace.project().clone(),
|
||||||
|
workspace.app_state().fs.clone(),
|
||||||
|
),
|
||||||
window,
|
window,
|
||||||
cx,
|
cx,
|
||||||
);
|
);
|
||||||
|
|
|
@ -4,7 +4,6 @@ mod file_finder_tests;
|
||||||
mod open_path_prompt_tests;
|
mod open_path_prompt_tests;
|
||||||
|
|
||||||
pub mod file_finder_settings;
|
pub mod file_finder_settings;
|
||||||
mod new_path_prompt;
|
|
||||||
mod open_path_prompt;
|
mod open_path_prompt;
|
||||||
|
|
||||||
use futures::future::join_all;
|
use futures::future::join_all;
|
||||||
|
@ -20,7 +19,6 @@ use gpui::{
|
||||||
KeyContext, Modifiers, ModifiersChangedEvent, ParentElement, Render, Styled, Task, WeakEntity,
|
KeyContext, Modifiers, ModifiersChangedEvent, ParentElement, Render, Styled, Task, WeakEntity,
|
||||||
Window, actions,
|
Window, actions,
|
||||||
};
|
};
|
||||||
use new_path_prompt::NewPathPrompt;
|
|
||||||
use open_path_prompt::OpenPathPrompt;
|
use open_path_prompt::OpenPathPrompt;
|
||||||
use picker::{Picker, PickerDelegate};
|
use picker::{Picker, PickerDelegate};
|
||||||
use project::{PathMatchCandidateSet, Project, ProjectPath, WorktreeId};
|
use project::{PathMatchCandidateSet, Project, ProjectPath, WorktreeId};
|
||||||
|
@ -85,8 +83,8 @@ pub fn init_settings(cx: &mut App) {
|
||||||
pub fn init(cx: &mut App) {
|
pub fn init(cx: &mut App) {
|
||||||
init_settings(cx);
|
init_settings(cx);
|
||||||
cx.observe_new(FileFinder::register).detach();
|
cx.observe_new(FileFinder::register).detach();
|
||||||
cx.observe_new(NewPathPrompt::register).detach();
|
|
||||||
cx.observe_new(OpenPathPrompt::register).detach();
|
cx.observe_new(OpenPathPrompt::register).detach();
|
||||||
|
cx.observe_new(OpenPathPrompt::register_new_path).detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FileFinder {
|
impl FileFinder {
|
||||||
|
|
|
@ -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()),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -2,6 +2,7 @@ use crate::file_finder_settings::FileFinderSettings;
|
||||||
use file_icons::FileIcons;
|
use file_icons::FileIcons;
|
||||||
use futures::channel::oneshot;
|
use futures::channel::oneshot;
|
||||||
use fuzzy::{StringMatch, StringMatchCandidate};
|
use fuzzy::{StringMatch, StringMatchCandidate};
|
||||||
|
use gpui::{HighlightStyle, StyledText, Task};
|
||||||
use picker::{Picker, PickerDelegate};
|
use picker::{Picker, PickerDelegate};
|
||||||
use project::{DirectoryItem, DirectoryLister};
|
use project::{DirectoryItem, DirectoryLister};
|
||||||
use settings::Settings;
|
use settings::Settings;
|
||||||
|
@ -12,61 +13,136 @@ use std::{
|
||||||
atomic::{self, AtomicBool},
|
atomic::{self, AtomicBool},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use ui::{Context, ListItem, Window};
|
use ui::{Context, LabelLike, ListItem, Window};
|
||||||
use ui::{HighlightedLabel, ListItemSpacing, prelude::*};
|
use ui::{HighlightedLabel, ListItemSpacing, prelude::*};
|
||||||
use util::{maybe, paths::compare_paths};
|
use util::{maybe, paths::compare_paths};
|
||||||
use workspace::Workspace;
|
use workspace::Workspace;
|
||||||
|
|
||||||
pub(crate) struct OpenPathPrompt;
|
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 {
|
pub struct OpenPathDelegate {
|
||||||
tx: Option<oneshot::Sender<Option<Vec<PathBuf>>>>,
|
tx: Option<oneshot::Sender<Option<Vec<PathBuf>>>>,
|
||||||
lister: DirectoryLister,
|
lister: DirectoryLister,
|
||||||
selected_index: usize,
|
selected_index: usize,
|
||||||
directory_state: Option<DirectoryState>,
|
directory_state: DirectoryState,
|
||||||
matches: Vec<usize>,
|
|
||||||
string_matches: Vec<StringMatch>,
|
string_matches: Vec<StringMatch>,
|
||||||
cancel_flag: Arc<AtomicBool>,
|
cancel_flag: Arc<AtomicBool>,
|
||||||
should_dismiss: bool,
|
should_dismiss: bool,
|
||||||
|
replace_prompt: Task<()>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl OpenPathDelegate {
|
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 {
|
Self {
|
||||||
tx: Some(tx),
|
tx: Some(tx),
|
||||||
lister,
|
lister,
|
||||||
selected_index: 0,
|
selected_index: 0,
|
||||||
directory_state: None,
|
directory_state: DirectoryState::None {
|
||||||
matches: Vec::new(),
|
create: creating_path,
|
||||||
|
},
|
||||||
string_matches: Vec::new(),
|
string_matches: Vec::new(),
|
||||||
cancel_flag: Arc::new(AtomicBool::new(false)),
|
cancel_flag: Arc::new(AtomicBool::new(false)),
|
||||||
should_dismiss: true,
|
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"))]
|
#[cfg(any(test, feature = "test-support"))]
|
||||||
pub fn collect_match_candidates(&self) -> Vec<String> {
|
pub fn collect_match_candidates(&self) -> Vec<String> {
|
||||||
if let Some(state) = self.directory_state.as_ref() {
|
match &self.directory_state {
|
||||||
self.matches
|
DirectoryState::List { entries, .. } => self
|
||||||
|
.string_matches
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|&index| {
|
.filter_map(|string_match| {
|
||||||
state
|
entries
|
||||||
.match_candidates
|
.iter()
|
||||||
.get(index)
|
.find(|entry| entry.path.id == string_match.candidate_id)
|
||||||
.map(|candidate| candidate.path.string.clone())
|
.map(|candidate| candidate.path.string.clone())
|
||||||
})
|
})
|
||||||
.collect()
|
.collect(),
|
||||||
} else {
|
DirectoryState::Create {
|
||||||
Vec::new()
|
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)]
|
#[derive(Debug)]
|
||||||
struct DirectoryState {
|
enum DirectoryState {
|
||||||
path: String,
|
List {
|
||||||
match_candidates: Vec<CandidateInfo>,
|
parent_path: String,
|
||||||
|
entries: Vec<CandidateInfo>,
|
||||||
error: Option<SharedString>,
|
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)]
|
#[derive(Debug, Clone)]
|
||||||
|
@ -83,7 +159,19 @@ impl OpenPathPrompt {
|
||||||
) {
|
) {
|
||||||
workspace.set_prompt_for_open_path(Box::new(|workspace, lister, window, cx| {
|
workspace.set_prompt_for_open_path(Box::new(|workspace, lister, window, cx| {
|
||||||
let (tx, rx) = futures::channel::oneshot::channel();
|
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
|
rx
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
@ -91,13 +179,13 @@ impl OpenPathPrompt {
|
||||||
fn prompt_for_open_path(
|
fn prompt_for_open_path(
|
||||||
workspace: &mut Workspace,
|
workspace: &mut Workspace,
|
||||||
lister: DirectoryLister,
|
lister: DirectoryLister,
|
||||||
|
creating_path: bool,
|
||||||
tx: oneshot::Sender<Option<Vec<PathBuf>>>,
|
tx: oneshot::Sender<Option<Vec<PathBuf>>>,
|
||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut Context<Workspace>,
|
cx: &mut Context<Workspace>,
|
||||||
) {
|
) {
|
||||||
workspace.toggle_modal(window, cx, |window, cx| {
|
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 picker = Picker::uniform_list(delegate, window, cx).width(rems(34.));
|
||||||
let query = lister.default_query(cx);
|
let query = lister.default_query(cx);
|
||||||
picker.set_query(query, window, cx);
|
picker.set_query(query, window, cx);
|
||||||
|
@ -110,7 +198,16 @@ impl PickerDelegate for OpenPathDelegate {
|
||||||
type ListItem = ui::ListItem;
|
type ListItem = ui::ListItem;
|
||||||
|
|
||||||
fn match_count(&self) -> usize {
|
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 {
|
fn selected_index(&self) -> usize {
|
||||||
|
@ -127,127 +224,196 @@ impl PickerDelegate for OpenPathDelegate {
|
||||||
query: String,
|
query: String,
|
||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut Context<Picker<Self>>,
|
cx: &mut Context<Picker<Self>>,
|
||||||
) -> gpui::Task<()> {
|
) -> Task<()> {
|
||||||
let lister = self.lister.clone();
|
let lister = &self.lister;
|
||||||
let query_path = Path::new(&query);
|
let last_item = Path::new(&query)
|
||||||
let last_item = query_path
|
|
||||||
.file_name()
|
.file_name()
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.to_string_lossy()
|
.to_string_lossy();
|
||||||
.to_string();
|
let (mut dir, suffix) = if let Some(dir) = query.strip_suffix(last_item.as_ref()) {
|
||||||
let (mut dir, suffix) = if let Some(dir) = query.strip_suffix(&last_item) {
|
(dir.to_string(), last_item.into_owned())
|
||||||
(dir.to_string(), last_item)
|
|
||||||
} else {
|
} else {
|
||||||
(query, String::new())
|
(query, String::new())
|
||||||
};
|
};
|
||||||
|
|
||||||
if dir == "" {
|
if dir == "" {
|
||||||
#[cfg(not(target_os = "windows"))]
|
dir = PROMPT_ROOT.to_string();
|
||||||
{
|
|
||||||
dir = "/".to_string();
|
|
||||||
}
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
{
|
|
||||||
dir = "C:\\".to_string();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let query = if self
|
let query = match &self.directory_state {
|
||||||
.directory_state
|
DirectoryState::List { parent_path, .. } => {
|
||||||
.as_ref()
|
if parent_path == &dir {
|
||||||
.map_or(false, |s| s.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
|
None
|
||||||
} else {
|
} else {
|
||||||
Some(lister.list_directory(dir.clone(), cx))
|
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));
|
self.cancel_flag = Arc::new(AtomicBool::new(false));
|
||||||
let cancel_flag = self.cancel_flag.clone();
|
let cancel_flag = self.cancel_flag.clone();
|
||||||
|
|
||||||
cx.spawn_in(window, async move |this, cx| {
|
cx.spawn_in(window, async move |this, cx| {
|
||||||
if let Some(query) = query {
|
if let Some(query) = query {
|
||||||
let paths = query.await;
|
let paths = query.await;
|
||||||
if cancel_flag.load(atomic::Ordering::Relaxed) {
|
if cancel_flag.load(atomic::Ordering::Acquire) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.update(cx, |this, _| {
|
if this
|
||||||
this.delegate.directory_state = Some(match paths {
|
.update(cx, |this, _| {
|
||||||
Ok(mut paths) => {
|
let new_state = match &this.delegate.directory_state {
|
||||||
if dir == "/" {
|
DirectoryState::None { create: false }
|
||||||
paths.push(DirectoryItem {
|
| DirectoryState::List { .. } => match paths {
|
||||||
is_dir: true,
|
Ok(paths) => DirectoryState::List {
|
||||||
path: Default::default(),
|
entries: path_candidates(&dir, paths),
|
||||||
});
|
parent_path: dir.clone(),
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
error: None,
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(err) => DirectoryState {
|
|
||||||
match_candidates: vec![],
|
|
||||||
path: dir,
|
|
||||||
error: Some(err.to_string().into()),
|
|
||||||
},
|
},
|
||||||
|
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,
|
||||||
})
|
})
|
||||||
.ok();
|
};
|
||||||
|
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
|
let Ok(mut new_entries) =
|
||||||
.update(cx, |this, cx| {
|
this.update(cx, |this, _| match &this.delegate.directory_state {
|
||||||
let directory_state = this.delegate.directory_state.as_ref()?;
|
DirectoryState::List {
|
||||||
if directory_state.error.is_some() {
|
entries,
|
||||||
this.delegate.matches.clear();
|
error: None,
|
||||||
this.delegate.selected_index = 0;
|
..
|
||||||
cx.notify();
|
}
|
||||||
return None;
|
| DirectoryState::Create { entries, .. } => entries.clone(),
|
||||||
|
DirectoryState::List { error: Some(_), .. } | DirectoryState::None { .. } => {
|
||||||
|
Vec::new()
|
||||||
}
|
}
|
||||||
|
|
||||||
Some(directory_state.match_candidates.clone())
|
|
||||||
})
|
})
|
||||||
.unwrap_or(None);
|
else {
|
||||||
|
|
||||||
let Some(mut match_candidates) = match_candidates else {
|
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
if !suffix.starts_with('.') {
|
if !suffix.starts_with('.') {
|
||||||
match_candidates.retain(|m| !m.path.string.starts_with('.'));
|
new_entries.retain(|entry| !entry.path.string.starts_with('.'));
|
||||||
}
|
}
|
||||||
|
if suffix.is_empty() {
|
||||||
if suffix == "" {
|
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
this.delegate.matches.clear();
|
this.delegate.selected_index = 0;
|
||||||
this.delegate.string_matches.clear();
|
this.delegate.string_matches = new_entries
|
||||||
this.delegate
|
.iter()
|
||||||
.matches
|
.map(|m| StringMatch {
|
||||||
.extend(match_candidates.iter().map(|m| m.path.id));
|
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();
|
cx.notify();
|
||||||
})
|
})
|
||||||
.ok();
|
.ok();
|
||||||
return;
|
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(
|
let matches = fuzzy::match_strings(
|
||||||
candidates.as_slice(),
|
candidates.as_slice(),
|
||||||
&suffix,
|
&suffix,
|
||||||
|
@ -257,27 +423,57 @@ impl PickerDelegate for OpenPathDelegate {
|
||||||
cx.background_executor().clone(),
|
cx.background_executor().clone(),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
if cancel_flag.load(atomic::Ordering::Relaxed) {
|
if cancel_flag.load(atomic::Ordering::Acquire) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
this.delegate.matches.clear();
|
this.delegate.selected_index = 0;
|
||||||
this.delegate.string_matches = matches.clone();
|
this.delegate.string_matches = matches.clone();
|
||||||
this.delegate
|
this.delegate.string_matches.sort_by_key(|m| {
|
||||||
.matches
|
|
||||||
.extend(matches.into_iter().map(|m| m.candidate_id));
|
|
||||||
this.delegate.matches.sort_by_key(|m| {
|
|
||||||
(
|
(
|
||||||
this.delegate.directory_state.as_ref().and_then(|d| {
|
new_entries
|
||||||
d.match_candidates
|
.iter()
|
||||||
.get(*m)
|
.find(|entry| entry.path.id == m.candidate_id)
|
||||||
.map(|c| !c.path.string.starts_with(&suffix))
|
.map(|entry| &entry.path)
|
||||||
}),
|
.map(|candidate| !candidate.string.starts_with(&suffix)),
|
||||||
*m,
|
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();
|
cx.notify();
|
||||||
})
|
})
|
||||||
.ok();
|
.ok();
|
||||||
|
@ -290,49 +486,107 @@ impl PickerDelegate for OpenPathDelegate {
|
||||||
_window: &mut Window,
|
_window: &mut Window,
|
||||||
_: &mut Context<Picker<Self>>,
|
_: &mut Context<Picker<Self>>,
|
||||||
) -> Option<String> {
|
) -> Option<String> {
|
||||||
|
let candidate = self.get_entry(self.selected_index)?;
|
||||||
Some(
|
Some(
|
||||||
maybe!({
|
maybe!({
|
||||||
let m = self.matches.get(self.selected_index)?;
|
match &self.directory_state {
|
||||||
let directory_state = self.directory_state.as_ref()?;
|
DirectoryState::Create { parent_path, .. } => Some(format!(
|
||||||
let candidate = directory_state.match_candidates.get(*m)?;
|
|
||||||
Some(format!(
|
|
||||||
"{}{}{}",
|
"{}{}{}",
|
||||||
directory_state.path,
|
parent_path,
|
||||||
candidate.path.string,
|
candidate.path.string,
|
||||||
if candidate.is_dir {
|
if candidate.is_dir {
|
||||||
MAIN_SEPARATOR_STR
|
MAIN_SEPARATOR_STR
|
||||||
} else {
|
} 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),
|
.unwrap_or(query),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn confirm(&mut self, _: bool, _: &mut Window, cx: &mut Context<Picker<Self>>) {
|
fn confirm(&mut self, _: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
|
||||||
let Some(m) = self.matches.get(self.selected_index) else {
|
let Some(candidate) = self.get_entry(self.selected_index) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let Some(directory_state) = self.directory_state.as_ref() else {
|
|
||||||
return;
|
match &self.directory_state {
|
||||||
};
|
DirectoryState::None { .. } => return,
|
||||||
let Some(candidate) = directory_state.match_candidates.get(*m) else {
|
DirectoryState::List { parent_path, .. } => {
|
||||||
return;
|
let confirmed_path =
|
||||||
};
|
if parent_path == PROMPT_ROOT && candidate.path.string.is_empty() {
|
||||||
let result = if directory_state.path == "/" && candidate.path.string.is_empty() {
|
PathBuf::from(PROMPT_ROOT)
|
||||||
PathBuf::from("/")
|
|
||||||
} else {
|
} else {
|
||||||
Path::new(
|
Path::new(self.lister.resolve_tilde(parent_path, cx).as_ref())
|
||||||
self.lister
|
|
||||||
.resolve_tilde(&directory_state.path, cx)
|
|
||||||
.as_ref(),
|
|
||||||
)
|
|
||||||
.join(&candidate.path.string)
|
.join(&candidate.path.string)
|
||||||
};
|
};
|
||||||
if let Some(tx) = self.tx.take() {
|
if let Some(tx) = self.tx.take() {
|
||||||
tx.send(Some(vec![result])).ok();
|
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);
|
cx.emit(gpui::DismissEvent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -351,19 +605,30 @@ impl PickerDelegate for OpenPathDelegate {
|
||||||
&self,
|
&self,
|
||||||
ix: usize,
|
ix: usize,
|
||||||
selected: bool,
|
selected: bool,
|
||||||
_window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut Context<Picker<Self>>,
|
cx: &mut Context<Picker<Self>>,
|
||||||
) -> Option<Self::ListItem> {
|
) -> Option<Self::ListItem> {
|
||||||
let settings = FileFinderSettings::get_global(cx);
|
let settings = FileFinderSettings::get_global(cx);
|
||||||
let m = self.matches.get(ix)?;
|
let candidate = self.get_entry(ix)?;
|
||||||
let directory_state = self.directory_state.as_ref()?;
|
let match_positions = match &self.directory_state {
|
||||||
let candidate = directory_state.match_candidates.get(*m)?;
|
DirectoryState::List { .. } => self.string_matches.get(ix)?.positions.clone(),
|
||||||
let highlight_positions = self
|
DirectoryState::Create { user_input, .. } => {
|
||||||
.string_matches
|
if let Some(user_input) = user_input {
|
||||||
.iter()
|
if !user_input.exists || !user_input.is_dir {
|
||||||
.find(|string_match| string_match.candidate_id == *m)
|
if ix == 0 {
|
||||||
.map(|string_match| string_match.positions.clone())
|
Vec::new()
|
||||||
.unwrap_or_default();
|
} 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!({
|
let file_icon = maybe!({
|
||||||
if !settings.file_icons {
|
if !settings.file_icons {
|
||||||
|
@ -378,34 +643,128 @@ impl PickerDelegate for OpenPathDelegate {
|
||||||
Some(Icon::from_path(icon).color(Color::Muted))
|
Some(Icon::from_path(icon).color(Color::Muted))
|
||||||
});
|
});
|
||||||
|
|
||||||
Some(
|
match &self.directory_state {
|
||||||
|
DirectoryState::List { parent_path, .. } => Some(
|
||||||
ListItem::new(ix)
|
ListItem::new(ix)
|
||||||
.spacing(ListItemSpacing::Sparse)
|
.spacing(ListItemSpacing::Sparse)
|
||||||
.start_slot::<Icon>(file_icon)
|
.start_slot::<Icon>(file_icon)
|
||||||
.inset(true)
|
.inset(true)
|
||||||
.toggle_state(selected)
|
.toggle_state(selected)
|
||||||
.child(HighlightedLabel::new(
|
.child(HighlightedLabel::new(
|
||||||
if directory_state.path == "/" {
|
if parent_path == PROMPT_ROOT {
|
||||||
format!("/{}", candidate.path.string)
|
format!("{}{}", PROMPT_ROOT, candidate.path.string)
|
||||||
} else {
|
} else {
|
||||||
candidate.path.string.clone()
|
candidate.path.string.clone()
|
||||||
},
|
},
|
||||||
highlight_positions,
|
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> {
|
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())
|
Some(match &self.directory_state {
|
||||||
{
|
DirectoryState::Create { .. } => SharedString::from("Type a path…"),
|
||||||
error
|
DirectoryState::List {
|
||||||
} else {
|
error: Some(error), ..
|
||||||
"No such file or directory".into()
|
} => error.clone(),
|
||||||
};
|
DirectoryState::List { .. } | DirectoryState::None { .. } => {
|
||||||
Some(text)
|
SharedString::from("No such file or directory")
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
|
fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
|
||||||
Arc::from(format!("[directory{MAIN_SEPARATOR_STR}]filename.ext"))
|
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()
|
||||||
|
}
|
||||||
|
|
|
@ -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 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");
|
let query = path!("/root");
|
||||||
insert_query(query, &picker, cx).await;
|
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 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.
|
// Confirm completion for the query "/root", since it's a directory, it should add a trailing slash.
|
||||||
let query = path!("/root");
|
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 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.
|
// Support both forward and backward slashes.
|
||||||
let query = "C:/root/";
|
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> {
|
fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
|
||||||
cx.update(|cx| {
|
cx.update(|cx| {
|
||||||
let state = AppState::test(cx);
|
let state = AppState::test(cx);
|
||||||
|
@ -266,11 +314,12 @@ fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
|
||||||
|
|
||||||
fn build_open_path_prompt(
|
fn build_open_path_prompt(
|
||||||
project: Entity<Project>,
|
project: Entity<Project>,
|
||||||
|
creating_path: bool,
|
||||||
cx: &mut TestAppContext,
|
cx: &mut TestAppContext,
|
||||||
) -> (Entity<Picker<OpenPathDelegate>>, &mut VisualTestContext) {
|
) -> (Entity<Picker<OpenPathDelegate>>, &mut VisualTestContext) {
|
||||||
let (tx, _) = futures::channel::oneshot::channel();
|
let (tx, _) = futures::channel::oneshot::channel();
|
||||||
let lister = project::DirectoryLister::Project(project.clone());
|
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));
|
let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
|
||||||
(
|
(
|
||||||
|
|
|
@ -770,13 +770,26 @@ pub struct DirectoryItem {
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub enum DirectoryLister {
|
pub enum DirectoryLister {
|
||||||
Project(Entity<Project>),
|
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 {
|
impl DirectoryLister {
|
||||||
pub fn is_local(&self, cx: &App) -> bool {
|
pub fn is_local(&self, cx: &App) -> bool {
|
||||||
match self {
|
match self {
|
||||||
DirectoryLister::Local(_) => true,
|
DirectoryLister::Local(..) => true,
|
||||||
DirectoryLister::Project(project) => project.read(cx).is_local(),
|
DirectoryLister::Project(project) => project.read(cx).is_local(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -790,12 +803,28 @@ impl DirectoryLister {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn default_query(&self, cx: &mut App) -> String {
|
pub fn default_query(&self, cx: &mut App) -> String {
|
||||||
if let DirectoryLister::Project(project) = self {
|
let separator = std::path::MAIN_SEPARATOR_STR;
|
||||||
if let Some(worktree) = project.read(cx).visible_worktrees(cx).next() {
|
match self {
|
||||||
return worktree.read(cx).abs_path().to_string_lossy().to_string();
|
DirectoryLister::Project(project) => project,
|
||||||
|
DirectoryLister::Local(project, _) => project,
|
||||||
}
|
}
|
||||||
};
|
.read(cx)
|
||||||
format!("~{}", std::path::MAIN_SEPARATOR_STR)
|
.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}")
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn list_directory(&self, path: String, cx: &mut App) -> Task<Result<Vec<DirectoryItem>>> {
|
pub fn list_directory(&self, path: String, cx: &mut App) -> Task<Result<Vec<DirectoryItem>>> {
|
||||||
|
@ -803,7 +832,7 @@ impl DirectoryLister {
|
||||||
DirectoryLister::Project(project) => {
|
DirectoryLister::Project(project) => {
|
||||||
project.update(cx, |project, cx| project.list_directory(path, cx))
|
project.update(cx, |project, cx| project.list_directory(path, cx))
|
||||||
}
|
}
|
||||||
DirectoryLister::Local(fs) => {
|
DirectoryLister::Local(_, fs) => {
|
||||||
let fs = fs.clone();
|
let fs = fs.clone();
|
||||||
cx.background_spawn(async move {
|
cx.background_spawn(async move {
|
||||||
let mut results = vec![];
|
let mut results = vec![];
|
||||||
|
@ -4049,7 +4078,7 @@ impl Project {
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) -> Task<Result<Vec<DirectoryItem>>> {
|
) -> Task<Result<Vec<DirectoryItem>>> {
|
||||||
if self.is_local() {
|
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() {
|
} else if let Some(session) = self.ssh_client.as_ref() {
|
||||||
let path_buf = PathBuf::from(query);
|
let path_buf = PathBuf::from(query);
|
||||||
let request = proto::ListRemoteDirectory {
|
let request = proto::ListRemoteDirectory {
|
||||||
|
|
|
@ -147,7 +147,7 @@ impl ProjectPicker {
|
||||||
) -> Entity<Self> {
|
) -> Entity<Self> {
|
||||||
let (tx, rx) = oneshot::channel();
|
let (tx, rx) = oneshot::channel();
|
||||||
let lister = project::DirectoryLister::Project(project.clone());
|
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 = cx.new(|cx| {
|
||||||
let picker = Picker::uniform_list(delegate, window, cx)
|
let picker = Picker::uniform_list(delegate, window, cx)
|
||||||
|
|
|
@ -25,7 +25,7 @@ use gpui::{
|
||||||
use itertools::Itertools;
|
use itertools::Itertools;
|
||||||
use language::DiagnosticSeverity;
|
use language::DiagnosticSeverity;
|
||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
use project::{Project, ProjectEntryId, ProjectPath, WorktreeId};
|
use project::{DirectoryLister, Project, ProjectEntryId, ProjectPath, WorktreeId};
|
||||||
use schemars::JsonSchema;
|
use schemars::JsonSchema;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use settings::{Settings, SettingsStore};
|
use settings::{Settings, SettingsStore};
|
||||||
|
@ -1921,24 +1921,56 @@ impl Pane {
|
||||||
})?
|
})?
|
||||||
.await?;
|
.await?;
|
||||||
} else if can_save_as && is_singleton {
|
} 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.activate_item(item_ix, true, true, window, cx);
|
||||||
pane.workspace.update(cx, |workspace, 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| {
|
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);
|
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 {
|
} else {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
save_task.await?;
|
||||||
|
return Ok(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -899,9 +899,10 @@ pub enum OpenVisible {
|
||||||
type PromptForNewPath = Box<
|
type PromptForNewPath = Box<
|
||||||
dyn Fn(
|
dyn Fn(
|
||||||
&mut Workspace,
|
&mut Workspace,
|
||||||
|
DirectoryLister,
|
||||||
&mut Window,
|
&mut Window,
|
||||||
&mut Context<Workspace>,
|
&mut Context<Workspace>,
|
||||||
) -> oneshot::Receiver<Option<ProjectPath>>,
|
) -> oneshot::Receiver<Option<Vec<PathBuf>>>,
|
||||||
>;
|
>;
|
||||||
|
|
||||||
type PromptForOpenPath = Box<
|
type PromptForOpenPath = Box<
|
||||||
|
@ -1874,25 +1875,25 @@ impl Workspace {
|
||||||
let (tx, rx) = oneshot::channel();
|
let (tx, rx) = oneshot::channel();
|
||||||
let abs_path = cx.prompt_for_paths(path_prompt_options);
|
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 {
|
let Ok(result) = abs_path.await else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok(result) => {
|
Ok(result) => {
|
||||||
tx.send(result).log_err();
|
tx.send(result).ok();
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
let rx = this.update_in(cx, |this, window, cx| {
|
let rx = workspace.update_in(cx, |workspace, window, cx| {
|
||||||
this.show_portal_error(err.to_string(), cx);
|
workspace.show_portal_error(err.to_string(), cx);
|
||||||
let prompt = this.on_prompt_for_open_path.take().unwrap();
|
let prompt = workspace.on_prompt_for_open_path.take().unwrap();
|
||||||
let rx = prompt(this, lister, window, cx);
|
let rx = prompt(workspace, lister, window, cx);
|
||||||
this.on_prompt_for_open_path = Some(prompt);
|
workspace.on_prompt_for_open_path = Some(prompt);
|
||||||
rx
|
rx
|
||||||
})?;
|
})?;
|
||||||
if let Ok(path) = rx.await {
|
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(
|
pub fn prompt_for_new_path(
|
||||||
&mut self,
|
&mut self,
|
||||||
|
lister: DirectoryLister,
|
||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) -> oneshot::Receiver<Option<ProjectPath>> {
|
) -> oneshot::Receiver<Option<Vec<PathBuf>>> {
|
||||||
if (self.project.read(cx).is_via_collab() || self.project.read(cx).is_via_ssh())
|
if self.project.read(cx).is_via_collab()
|
||||||
|
|| self.project.read(cx).is_via_ssh()
|
||||||
|| !WorkspaceSettings::get_global(cx).use_system_path_prompts
|
|| !WorkspaceSettings::get_global(cx).use_system_path_prompts
|
||||||
{
|
{
|
||||||
let prompt = self.on_prompt_for_new_path.take().unwrap();
|
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);
|
self.on_prompt_for_new_path = Some(prompt);
|
||||||
return rx;
|
return rx;
|
||||||
}
|
}
|
||||||
|
|
||||||
let (tx, rx) = oneshot::channel();
|
let (tx, rx) = oneshot::channel();
|
||||||
cx.spawn_in(window, async move |this, cx| {
|
cx.spawn_in(window, async move |workspace, cx| {
|
||||||
let abs_path = this.update(cx, |this, cx| {
|
let abs_path = workspace.update(cx, |workspace, cx| {
|
||||||
let mut relative_to = this
|
let relative_to = workspace
|
||||||
.most_recent_active_path(cx)
|
.most_recent_active_path(cx)
|
||||||
.and_then(|p| p.parent().map(|p| p.to_path_buf()));
|
.and_then(|p| p.parent().map(|p| p.to_path_buf()))
|
||||||
if relative_to.is_none() {
|
.or_else(|| {
|
||||||
let project = this.project.read(cx);
|
let project = workspace.project.read(cx);
|
||||||
relative_to = project
|
project.visible_worktrees(cx).find_map(|worktree| {
|
||||||
.visible_worktrees(cx)
|
|
||||||
.filter_map(|worktree| {
|
|
||||||
Some(worktree.read(cx).as_local()?.abs_path().to_path_buf())
|
Some(worktree.read(cx).as_local()?.abs_path().to_path_buf())
|
||||||
})
|
})
|
||||||
.next()
|
})
|
||||||
};
|
.or_else(std::env::home_dir)
|
||||||
|
.unwrap_or_else(|| PathBuf::from(""));
|
||||||
cx.prompt_for_new_path(&relative_to.unwrap_or_else(|| PathBuf::from("")))
|
cx.prompt_for_new_path(&relative_to)
|
||||||
})?;
|
})?;
|
||||||
let abs_path = match abs_path.await? {
|
let abs_path = match abs_path.await? {
|
||||||
Ok(path) => path,
|
Ok(path) => path,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
let rx = this.update_in(cx, |this, window, cx| {
|
let rx = workspace.update_in(cx, |workspace, window, cx| {
|
||||||
this.show_portal_error(err.to_string(), cx);
|
workspace.show_portal_error(err.to_string(), cx);
|
||||||
|
|
||||||
let prompt = this.on_prompt_for_new_path.take().unwrap();
|
let prompt = workspace.on_prompt_for_new_path.take().unwrap();
|
||||||
let rx = prompt(this, window, cx);
|
let rx = prompt(workspace, lister, window, cx);
|
||||||
this.on_prompt_for_new_path = Some(prompt);
|
workspace.on_prompt_for_new_path = Some(prompt);
|
||||||
rx
|
rx
|
||||||
})?;
|
})?;
|
||||||
if let Ok(path) = rx.await {
|
if let Ok(path) = rx.await {
|
||||||
tx.send(path).log_err();
|
tx.send(path).ok();
|
||||||
}
|
}
|
||||||
return anyhow::Ok(());
|
return anyhow::Ok(());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let project_path = abs_path.and_then(|abs_path| {
|
tx.send(abs_path.map(|path| vec![path])).ok();
|
||||||
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();
|
|
||||||
}
|
|
||||||
anyhow::Ok(())
|
anyhow::Ok(())
|
||||||
})
|
})
|
||||||
.detach_and_log_err(cx);
|
.detach();
|
||||||
|
|
||||||
rx
|
rx
|
||||||
}
|
}
|
||||||
|
|
|
@ -503,7 +503,10 @@ fn register_actions(
|
||||||
directories: true,
|
directories: true,
|
||||||
multiple: true,
|
multiple: true,
|
||||||
},
|
},
|
||||||
DirectoryLister::Local(workspace.app_state().fs.clone()),
|
DirectoryLister::Local(
|
||||||
|
workspace.project().clone(),
|
||||||
|
workspace.app_state().fs.clone(),
|
||||||
|
),
|
||||||
window,
|
window,
|
||||||
cx,
|
cx,
|
||||||
);
|
);
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue