diff --git a/crates/file_finder/src/file_finder.rs b/crates/file_finder/src/file_finder.rs index 0d0b965bc5..ecabc209bd 100644 --- a/crates/file_finder/src/file_finder.rs +++ b/crates/file_finder/src/file_finder.rs @@ -1,5 +1,7 @@ #[cfg(test)] mod file_finder_tests; +#[cfg(test)] +mod open_path_prompt_tests; pub mod file_finder_settings; mod new_path_prompt; diff --git a/crates/file_finder/src/open_path_prompt.rs b/crates/file_finder/src/open_path_prompt.rs index 71203874f6..254e64d83a 100644 --- a/crates/file_finder/src/open_path_prompt.rs +++ b/crates/file_finder/src/open_path_prompt.rs @@ -3,7 +3,7 @@ use fuzzy::StringMatchCandidate; use picker::{Picker, PickerDelegate}; use project::DirectoryLister; use std::{ - path::{Path, PathBuf}, + path::{Path, PathBuf, MAIN_SEPARATOR_STR}, sync::{ atomic::{self, AtomicBool}, Arc, @@ -38,14 +38,38 @@ impl OpenPathDelegate { should_dismiss: true, } } + + #[cfg(any(test, feature = "test-support"))] + pub fn collect_match_candidates(&self) -> Vec { + if let Some(state) = self.directory_state.as_ref() { + self.matches + .iter() + .filter_map(|&index| { + state + .match_candidates + .get(index) + .map(|candidate| candidate.path.string.clone()) + }) + .collect() + } else { + Vec::new() + } + } } +#[derive(Debug)] struct DirectoryState { path: String, - match_candidates: Vec, + match_candidates: Vec, error: Option, } +#[derive(Debug, Clone)] +struct CandidateInfo { + path: StringMatchCandidate, + is_dir: bool, +} + impl OpenPathPrompt { pub(crate) fn register( workspace: &mut Workspace, @@ -93,8 +117,6 @@ impl PickerDelegate for OpenPathDelegate { cx.notify(); } - // todo(windows) - // Is this method woring correctly on Windows? This method uses `/` for path separator. fn update_matches( &mut self, query: String, @@ -102,13 +124,26 @@ impl PickerDelegate for OpenPathDelegate { cx: &mut Context>, ) -> gpui::Task<()> { let lister = self.lister.clone(); - let (mut dir, suffix) = if let Some(index) = query.rfind('/') { - (query[..index].to_string(), query[index + 1..].to_string()) + let query_path = Path::new(&query); + let last_item = query_path + .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) } else { (query, String::new()) }; if dir == "" { - dir = "/".to_string(); + #[cfg(not(target_os = "windows"))] + { + dir = "/".to_string(); + } + #[cfg(target_os = "windows")] + { + dir = "C:\\".to_string(); + } } let query = if self @@ -134,12 +169,16 @@ impl PickerDelegate for OpenPathDelegate { this.update(&mut cx, |this, _| { this.delegate.directory_state = Some(match paths { Ok(mut paths) => { - paths.sort_by(|a, b| compare_paths((a, true), (b, true))); + paths.sort_by(|a, b| compare_paths((&a.path, true), (&b.path, true))); let match_candidates = paths .iter() .enumerate() - .map(|(ix, path)| { - StringMatchCandidate::new(ix, &path.to_string_lossy()) + .map(|(ix, item)| CandidateInfo { + path: StringMatchCandidate::new( + ix, + &item.path.to_string_lossy(), + ), + is_dir: item.is_dir, }) .collect::>(); @@ -178,7 +217,7 @@ impl PickerDelegate for OpenPathDelegate { }; if !suffix.starts_with('.') { - match_candidates.retain(|m| !m.string.starts_with('.')); + match_candidates.retain(|m| !m.path.string.starts_with('.')); } if suffix == "" { @@ -186,7 +225,7 @@ impl PickerDelegate for OpenPathDelegate { this.delegate.matches.clear(); this.delegate .matches - .extend(match_candidates.iter().map(|m| m.id)); + .extend(match_candidates.iter().map(|m| m.path.id)); cx.notify(); }) @@ -194,8 +233,9 @@ impl PickerDelegate for OpenPathDelegate { return; } + let candidates = match_candidates.iter().map(|m| &m.path).collect::>(); let matches = fuzzy::match_strings( - match_candidates.as_slice(), + candidates.as_slice(), &suffix, false, 100, @@ -217,7 +257,7 @@ impl PickerDelegate for OpenPathDelegate { this.delegate.directory_state.as_ref().and_then(|d| { d.match_candidates .get(*m) - .map(|c| !c.string.starts_with(&suffix)) + .map(|c| !c.path.string.starts_with(&suffix)) }), *m, ) @@ -239,7 +279,16 @@ impl PickerDelegate for OpenPathDelegate { let m = self.matches.get(self.selected_index)?; let directory_state = self.directory_state.as_ref()?; let candidate = directory_state.match_candidates.get(*m)?; - Some(format!("{}/{}", directory_state.path, candidate.string)) + Some(format!( + "{}{}{}", + directory_state.path, + candidate.path.string, + if candidate.is_dir { + MAIN_SEPARATOR_STR + } else { + "" + } + )) }) .unwrap_or(query), ) @@ -260,7 +309,7 @@ impl PickerDelegate for OpenPathDelegate { .resolve_tilde(&directory_state.path, cx) .as_ref(), ) - .join(&candidate.string); + .join(&candidate.path.string); if let Some(tx) = self.tx.take() { tx.send(Some(vec![result])).ok(); } @@ -294,7 +343,7 @@ impl PickerDelegate for OpenPathDelegate { .spacing(ListItemSpacing::Sparse) .inset(true) .toggle_state(selected) - .child(LabelLike::new().child(candidate.string.clone())), + .child(LabelLike::new().child(candidate.path.string.clone())), ) } @@ -307,6 +356,6 @@ impl PickerDelegate for OpenPathDelegate { } fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { - Arc::from("[directory/]filename.ext") + Arc::from(format!("[directory{MAIN_SEPARATOR_STR}]filename.ext")) } } diff --git a/crates/file_finder/src/open_path_prompt_tests.rs b/crates/file_finder/src/open_path_prompt_tests.rs new file mode 100644 index 0000000000..1303f3e75a --- /dev/null +++ b/crates/file_finder/src/open_path_prompt_tests.rs @@ -0,0 +1,324 @@ +use std::sync::Arc; + +use gpui::{AppContext, Entity, TestAppContext, VisualTestContext}; +use picker::{Picker, PickerDelegate}; +use project::Project; +use serde_json::json; +use ui::rems; +use util::path; +use workspace::{AppState, Workspace}; + +use crate::OpenPathDelegate; + +#[gpui::test] +async fn test_open_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, cx); + + let query = path!("/root"); + insert_query(query, &picker, cx).await; + assert_eq!(collect_match_candidates(&picker, cx), vec!["root"]); + + // If the query ends with a slash, the picker should show the contents of the directory. + let query = path!("/root/"); + insert_query(query, &picker, cx).await; + assert_eq!( + collect_match_candidates(&picker, cx), + vec!["a1", "a2", "a3", "dir1", "dir2"] + ); + + // Show candidates for the query "a". + let query = path!("/root/a"); + insert_query(query, &picker, cx).await; + assert_eq!( + collect_match_candidates(&picker, cx), + vec!["a1", "a2", "a3"] + ); + + // Show candidates for the query "d". + let query = path!("/root/d"); + insert_query(query, &picker, cx).await; + assert_eq!(collect_match_candidates(&picker, cx), vec!["dir1", "dir2"]); + + let query = path!("/root/dir2"); + insert_query(query, &picker, cx).await; + assert_eq!(collect_match_candidates(&picker, cx), vec!["dir2"]); + + let query = path!("/root/dir2/"); + insert_query(query, &picker, cx).await; + assert_eq!( + collect_match_candidates(&picker, cx), + vec!["c", "d1", "d2", "d3", "dir3", "dir4"] + ); + + // Show candidates for the query "d". + let query = path!("/root/dir2/d"); + insert_query(query, &picker, cx).await; + assert_eq!( + collect_match_candidates(&picker, cx), + vec!["d1", "d2", "d3", "dir3", "dir4"] + ); + + let query = path!("/root/dir2/di"); + insert_query(query, &picker, cx).await; + assert_eq!(collect_match_candidates(&picker, cx), vec!["dir3", "dir4"]); +} + +#[gpui::test] +async fn test_open_path_prompt_completion(cx: &mut TestAppContext) { + let app_state = init_test(cx); + app_state + .fs + .as_fake() + .insert_tree( + path!("/root"), + json!({ + "a": "A", + "dir1": {}, + "dir2": { + "c": "C", + "d": "D", + "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, cx); + + // Confirm completion for the query "/root", since it's a directory, it should add a trailing slash. + let query = path!("/root"); + insert_query(query, &picker, cx).await; + assert_eq!(confirm_completion(query, 0, &picker, cx), path!("/root/")); + + // Confirm completion for the query "/root/", selecting the first candidate "a", since it's a file, it should not add a trailing slash. + let query = path!("/root/"); + insert_query(query, &picker, cx).await; + assert_eq!(confirm_completion(query, 0, &picker, cx), path!("/root/a")); + + // Confirm completion for the query "/root/", selecting the second candidate "dir1", since it's a directory, it should add a trailing slash. + let query = path!("/root/"); + insert_query(query, &picker, cx).await; + assert_eq!( + confirm_completion(query, 1, &picker, cx), + path!("/root/dir1/") + ); + + let query = path!("/root/a"); + insert_query(query, &picker, cx).await; + assert_eq!(confirm_completion(query, 0, &picker, cx), path!("/root/a")); + + let query = path!("/root/d"); + insert_query(query, &picker, cx).await; + assert_eq!( + confirm_completion(query, 1, &picker, cx), + path!("/root/dir2/") + ); + + let query = path!("/root/dir2"); + insert_query(query, &picker, cx).await; + assert_eq!( + confirm_completion(query, 0, &picker, cx), + path!("/root/dir2/") + ); + + let query = path!("/root/dir2/"); + insert_query(query, &picker, cx).await; + assert_eq!( + confirm_completion(query, 0, &picker, cx), + path!("/root/dir2/c") + ); + + let query = path!("/root/dir2/"); + insert_query(query, &picker, cx).await; + assert_eq!( + confirm_completion(query, 2, &picker, cx), + path!("/root/dir2/dir3/") + ); + + let query = path!("/root/dir2/d"); + insert_query(query, &picker, cx).await; + assert_eq!( + confirm_completion(query, 0, &picker, cx), + path!("/root/dir2/d") + ); + + let query = path!("/root/dir2/d"); + insert_query(query, &picker, cx).await; + assert_eq!( + confirm_completion(query, 1, &picker, cx), + path!("/root/dir2/dir3/") + ); + + let query = path!("/root/dir2/di"); + insert_query(query, &picker, cx).await; + assert_eq!( + confirm_completion(query, 1, &picker, cx), + path!("/root/dir2/dir4/") + ); +} + +#[gpui::test] +#[cfg(target_os = "windows")] +async fn test_open_path_prompt_on_windows(cx: &mut TestAppContext) { + let app_state = init_test(cx); + app_state + .fs + .as_fake() + .insert_tree( + path!("/root"), + json!({ + "a": "A", + "dir1": {}, + "dir2": {} + }), + ) + .await; + + let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await; + + let (picker, cx) = build_open_path_prompt(project, cx); + + // Support both forward and backward slashes. + let query = "C:/root/"; + insert_query(query, &picker, cx).await; + assert_eq!( + collect_match_candidates(&picker, cx), + vec!["a", "dir1", "dir2"] + ); + assert_eq!(confirm_completion(query, 0, &picker, cx), "C:/root/a"); + + let query = "C:\\root/"; + insert_query(query, &picker, cx).await; + assert_eq!( + collect_match_candidates(&picker, cx), + vec!["a", "dir1", "dir2"] + ); + assert_eq!(confirm_completion(query, 0, &picker, cx), "C:\\root/a"); + + let query = "C:\\root\\"; + insert_query(query, &picker, cx).await; + assert_eq!( + collect_match_candidates(&picker, cx), + vec!["a", "dir1", "dir2"] + ); + assert_eq!(confirm_completion(query, 0, &picker, cx), "C:\\root\\a"); + + // Confirm completion for the query "C:/root/d", selecting the second candidate "dir2", since it's a directory, it should add a trailing slash. + let query = "C:/root/d"; + insert_query(query, &picker, cx).await; + assert_eq!(collect_match_candidates(&picker, cx), vec!["dir1", "dir2"]); + assert_eq!(confirm_completion(query, 1, &picker, cx), "C:/root/dir2\\"); + + let query = "C:\\root/d"; + insert_query(query, &picker, cx).await; + assert_eq!(collect_match_candidates(&picker, cx), vec!["dir1", "dir2"]); + assert_eq!(confirm_completion(query, 0, &picker, cx), "C:\\root/dir1\\"); + + let query = "C:\\root\\d"; + insert_query(query, &picker, cx).await; + assert_eq!(collect_match_candidates(&picker, cx), vec!["dir1", "dir2"]); + assert_eq!( + confirm_completion(query, 0, &picker, cx), + "C:\\root\\dir1\\" + ); +} + +fn init_test(cx: &mut TestAppContext) -> Arc { + cx.update(|cx| { + let state = AppState::test(cx); + theme::init(theme::LoadThemes::JustBase, cx); + language::init(cx); + super::init(cx); + editor::init(cx); + workspace::init_settings(cx); + Project::init_settings(cx); + state + }) +} + +fn build_open_path_prompt( + project: Entity, + 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 (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx)); + ( + workspace.update_in(cx, |_, window, cx| { + cx.new(|cx| { + let picker = Picker::uniform_list(delegate, window, cx) + .width(rems(34.)) + .modal(false); + let query = lister.default_query(cx); + picker.set_query(query, window, cx); + picker + }) + }), + cx, + ) +} + +async fn insert_query( + query: &str, + picker: &Entity>, + cx: &mut VisualTestContext, +) { + picker + .update_in(cx, |f, window, cx| { + f.delegate.update_matches(query.to_string(), window, cx) + }) + .await; +} + +fn confirm_completion( + query: &str, + select: usize, + picker: &Entity>, + cx: &mut VisualTestContext, +) -> String { + picker + .update_in(cx, |f, window, cx| { + if f.delegate.selected_index() != select { + f.delegate.set_selected_index(select, window, cx); + } + f.delegate.confirm_completion(query.to_string(), window, cx) + }) + .unwrap() +} + +fn collect_match_candidates( + picker: &Entity>, + cx: &mut VisualTestContext, +) -> Vec { + picker.update(cx, |f, _| f.delegate.collect_match_candidates()) +} diff --git a/crates/fuzzy/src/matcher.rs b/crates/fuzzy/src/matcher.rs index 66a480d87a..5e520eccb7 100644 --- a/crates/fuzzy/src/matcher.rs +++ b/crates/fuzzy/src/matcher.rs @@ -1,5 +1,5 @@ use std::{ - borrow::Cow, + borrow::{Borrow, Cow}, sync::atomic::{self, AtomicBool}, }; @@ -50,22 +50,24 @@ impl<'a> Matcher<'a> { /// Filter and score fuzzy match candidates. Results are returned unsorted, in the same order as /// the input candidates. - pub fn match_candidates( + pub fn match_candidates( &mut self, prefix: &[char], lowercase_prefix: &[char], - candidates: impl Iterator, + candidates: impl Iterator, results: &mut Vec, cancel_flag: &AtomicBool, build_match: F, ) where + C: MatchCandidate, + T: Borrow, F: Fn(&C, f64, &Vec) -> R, { let mut candidate_chars = Vec::new(); let mut lowercase_candidate_chars = Vec::new(); for candidate in candidates { - if !candidate.has_chars(self.query_char_bag) { + if !candidate.borrow().has_chars(self.query_char_bag) { continue; } @@ -75,7 +77,7 @@ impl<'a> Matcher<'a> { candidate_chars.clear(); lowercase_candidate_chars.clear(); - for c in candidate.to_string().chars() { + for c in candidate.borrow().to_string().chars() { candidate_chars.push(c); lowercase_candidate_chars.append(&mut c.to_lowercase().collect::>()); } @@ -98,7 +100,11 @@ impl<'a> Matcher<'a> { ); if score > 0.0 { - results.push(build_match(&candidate, score, &self.match_positions)); + results.push(build_match( + candidate.borrow(), + score, + &self.match_positions, + )); } } } diff --git a/crates/fuzzy/src/strings.rs b/crates/fuzzy/src/strings.rs index 12888e42ed..bb5db63fd2 100644 --- a/crates/fuzzy/src/strings.rs +++ b/crates/fuzzy/src/strings.rs @@ -4,7 +4,7 @@ use crate::{ }; use gpui::BackgroundExecutor; use std::{ - borrow::Cow, + borrow::{Borrow, Cow}, cmp::{self, Ordering}, iter, ops::Range, @@ -113,14 +113,17 @@ impl Ord for StringMatch { } } -pub async fn match_strings( - candidates: &[StringMatchCandidate], +pub async fn match_strings( + candidates: &[T], query: &str, smart_case: bool, max_results: usize, cancel_flag: &AtomicBool, executor: BackgroundExecutor, -) -> Vec { +) -> Vec +where + T: Borrow + Sync, +{ if candidates.is_empty() || max_results == 0 { return Default::default(); } @@ -129,10 +132,10 @@ pub async fn match_strings( return candidates .iter() .map(|candidate| StringMatch { - candidate_id: candidate.id, + candidate_id: candidate.borrow().id, score: 0., positions: Default::default(), - string: candidate.string.clone(), + string: candidate.borrow().string.clone(), }) .collect(); } @@ -163,10 +166,12 @@ pub async fn match_strings( matcher.match_candidates( &[], &[], - candidates[segment_start..segment_end].iter(), + candidates[segment_start..segment_end] + .iter() + .map(|c| c.borrow()), results, cancel_flag, - |candidate, score, positions| StringMatch { + |candidate: &&StringMatchCandidate, score, positions| StringMatch { candidate_id: candidate.id, score, positions: positions.clone(), diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index b61466747b..2c61bdee4a 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -524,6 +524,12 @@ enum EntitySubscription { SettingsObserver(PendingEntitySubscription), } +#[derive(Debug, Clone)] +pub struct DirectoryItem { + pub path: PathBuf, + pub is_dir: bool, +} + #[derive(Clone)] pub enum DirectoryLister { Project(Entity), @@ -552,10 +558,10 @@ impl DirectoryLister { return worktree.read(cx).abs_path().to_string_lossy().to_string(); } }; - "~/".to_string() + format!("~{}", std::path::MAIN_SEPARATOR_STR) } - pub fn list_directory(&self, path: String, cx: &mut App) -> Task>> { + pub fn list_directory(&self, path: String, cx: &mut App) -> Task>> { match self { DirectoryLister::Project(project) => { project.update(cx, |project, cx| project.list_directory(path, cx)) @@ -568,8 +574,12 @@ impl DirectoryLister { let query = Path::new(expanded.as_ref()); let mut response = fs.read_dir(query).await?; while let Some(path) = response.next().await { - if let Some(file_name) = path?.file_name() { - results.push(PathBuf::from(file_name.to_os_string())); + let path = path?; + if let Some(file_name) = path.file_name() { + results.push(DirectoryItem { + path: PathBuf::from(file_name.to_os_string()), + is_dir: fs.is_dir(&path).await, + }); } } Ok(results) @@ -3491,7 +3501,7 @@ impl Project { &self, query: String, cx: &mut Context, - ) -> Task>> { + ) -> Task>> { if self.is_local() { DirectoryLister::Local(self.fs.clone()).list_directory(query, cx) } else if let Some(session) = self.ssh_client.as_ref() { @@ -3499,12 +3509,23 @@ impl Project { let request = proto::ListRemoteDirectory { dev_server_id: SSH_PROJECT_ID, path: path_buf.to_proto(), + config: Some(proto::ListRemoteDirectoryConfig { is_dir: true }), }; let response = session.read(cx).proto_client().request(request); cx.background_spawn(async move { - let response = response.await?; - Ok(response.entries.into_iter().map(PathBuf::from).collect()) + let proto::ListRemoteDirectoryResponse { + entries, + entry_info, + } = response.await?; + Ok(entries + .into_iter() + .zip(entry_info) + .map(|(entry, info)| DirectoryItem { + path: PathBuf::from(entry), + is_dir: info.is_dir, + }) + .collect()) }) } else { Task::ready(Err(anyhow!("cannot list directory in remote project"))) diff --git a/crates/proto/proto/zed.proto b/crates/proto/proto/zed.proto index 9716fafcd4..16af19c870 100644 --- a/crates/proto/proto/zed.proto +++ b/crates/proto/proto/zed.proto @@ -572,13 +572,23 @@ message JoinProject { uint64 project_id = 1; } +message ListRemoteDirectoryConfig { + bool is_dir = 1; +} + message ListRemoteDirectory { uint64 dev_server_id = 1; string path = 2; + ListRemoteDirectoryConfig config = 3; +} + +message EntryInfo { + bool is_dir = 1; } message ListRemoteDirectoryResponse { repeated string entries = 1; + repeated EntryInfo entry_info = 2; } message JoinProjectResponse { diff --git a/crates/remote_server/src/headless_project.rs b/crates/remote_server/src/headless_project.rs index 4f09647e17..fadd603b50 100644 --- a/crates/remote_server/src/headless_project.rs +++ b/crates/remote_server/src/headless_project.rs @@ -554,15 +554,29 @@ impl HeadlessProject { ) -> Result { let fs = cx.read_entity(&this, |this, _| this.fs.clone())?; let expanded = PathBuf::from_proto(shellexpand::tilde(&envelope.payload.path).to_string()); + let check_info = envelope + .payload + .config + .as_ref() + .is_some_and(|config| config.is_dir); let mut entries = Vec::new(); + let mut entry_info = Vec::new(); let mut response = fs.read_dir(&expanded).await?; while let Some(path) = response.next().await { - if let Some(file_name) = path?.file_name() { + let path = path?; + if let Some(file_name) = path.file_name() { entries.push(file_name.to_string_lossy().to_string()); + if check_info { + let is_dir = fs.is_dir(&path).await; + entry_info.push(proto::EntryInfo { is_dir }); + } } } - Ok(proto::ListRemoteDirectoryResponse { entries }) + Ok(proto::ListRemoteDirectoryResponse { + entries, + entry_info, + }) } pub async fn handle_get_path_metadata(