workspace: Add trailing `/` to directories on completion when using `OpenPathPrompt` (#25430)

张小白 created

Closes #25045

With the setting `"use_system_path_prompts": false`, previously, if the
completion target was a directory, no separator would be added after it,
requiring us to manually append a `/` or `\`. Now, if the completion
target is a directory, a `/` or `\` will be automatically added. On
Windows, both `/` and `\` are considered valid path separators.



https://github.com/user-attachments/assets/0594ce27-9693-4a49-ae0e-3ed29f62526a



Release Notes:

- N/A

Change summary

crates/file_finder/src/file_finder.rs            |   2 
crates/file_finder/src/open_path_prompt.rs       |  85 +++-
crates/file_finder/src/open_path_prompt_tests.rs | 324 ++++++++++++++++++
crates/fuzzy/src/matcher.rs                      |  18 
crates/fuzzy/src/strings.rs                      |  21 
crates/project/src/project.rs                    |  35 +
crates/proto/proto/zed.proto                     |  10 
crates/remote_server/src/headless_project.rs     |  18 
8 files changed, 472 insertions(+), 41 deletions(-)

Detailed changes

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<String> {
+        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<StringMatchCandidate>,
+    match_candidates: Vec<CandidateInfo>,
     error: Option<SharedString>,
 }
 
+#[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<Picker<Self>>,
     ) -> 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::<Vec<_>>();
 
@@ -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::<Vec<_>>();
             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<str> {
-        Arc::from("[directory/]filename.ext")
+        Arc::from(format!("[directory{MAIN_SEPARATOR_STR}]filename.ext"))
     }
 }

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<AppState> {
+    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<Project>,
+    cx: &mut TestAppContext,
+) -> (Entity<Picker<OpenPathDelegate>>, &mut VisualTestContext) {
+    let (tx, _) = futures::channel::oneshot::channel();
+    let lister = project::DirectoryLister::Project(project.clone());
+    let delegate = OpenPathDelegate::new(tx, lister.clone());
+
+    let (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<Picker<OpenPathDelegate>>,
+    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<Picker<OpenPathDelegate>>,
+    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<Picker<OpenPathDelegate>>,
+    cx: &mut VisualTestContext,
+) -> Vec<String> {
+    picker.update(cx, |f, _| f.delegate.collect_match_candidates())
+}

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<C: MatchCandidate, R, F>(
+    pub fn match_candidates<C, R, F, T>(
         &mut self,
         prefix: &[char],
         lowercase_prefix: &[char],
-        candidates: impl Iterator<Item = C>,
+        candidates: impl Iterator<Item = T>,
         results: &mut Vec<R>,
         cancel_flag: &AtomicBool,
         build_match: F,
     ) where
+        C: MatchCandidate,
+        T: Borrow<C>,
         F: Fn(&C, f64, &Vec<usize>) -> 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::<Vec<_>>());
             }
@@ -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,
+                ));
             }
         }
     }

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<T>(
+    candidates: &[T],
     query: &str,
     smart_case: bool,
     max_results: usize,
     cancel_flag: &AtomicBool,
     executor: BackgroundExecutor,
-) -> Vec<StringMatch> {
+) -> Vec<StringMatch>
+where
+    T: Borrow<StringMatchCandidate> + 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(),

crates/project/src/project.rs 🔗

@@ -524,6 +524,12 @@ enum EntitySubscription {
     SettingsObserver(PendingEntitySubscription<SettingsObserver>),
 }
 
+#[derive(Debug, Clone)]
+pub struct DirectoryItem {
+    pub path: PathBuf,
+    pub is_dir: bool,
+}
+
 #[derive(Clone)]
 pub enum DirectoryLister {
     Project(Entity<Project>),
@@ -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<Result<Vec<PathBuf>>> {
+    pub fn list_directory(&self, path: String, cx: &mut App) -> Task<Result<Vec<DirectoryItem>>> {
         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<Self>,
-    ) -> Task<Result<Vec<PathBuf>>> {
+    ) -> Task<Result<Vec<DirectoryItem>>> {
         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")))

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 {

crates/remote_server/src/headless_project.rs 🔗

@@ -554,15 +554,29 @@ impl HeadlessProject {
     ) -> Result<proto::ListRemoteDirectoryResponse> {
         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(