Allow opening the FS root dir as a remote project (#30190)

Max Brunsfeld and Agus Zubiaga created

### Todo

* [x] Allow opening `ssh://username@host:/` from the CLI
* [x] Allow selecting `/` in the `open path` picker
* [x] Allow selecting the home directory in the `open path` picker

Release Notes:

- Changed the initial state of the SSH project picker to show the full
path to your home directory on the remote machine, instead of `~`.
- Added the ability to open `/` as a project folder over SSH

---------

Co-authored-by: Agus Zubiaga <hi@aguz.me>

Change summary

crates/file_finder/src/open_path_prompt.rs   | 32 ++++++++--
crates/project/src/project.rs                | 13 ++-
crates/project/src/worktree_store.rs         |  2 
crates/project_panel/src/project_panel.rs    |  8 --
crates/recent_projects/src/remote_servers.rs | 66 ++++++++++++---------
crates/worktree/src/worktree.rs              |  2 
crates/zed/src/zed/open_listener.rs          | 29 ++++++++-
7 files changed, 98 insertions(+), 54 deletions(-)

Detailed changes

crates/file_finder/src/open_path_prompt.rs 🔗

@@ -1,7 +1,7 @@
 use futures::channel::oneshot;
 use fuzzy::{StringMatch, StringMatchCandidate};
 use picker::{Picker, PickerDelegate};
-use project::DirectoryLister;
+use project::{DirectoryItem, DirectoryLister};
 use std::{
     path::{MAIN_SEPARATOR_STR, Path, PathBuf},
     sync::{
@@ -137,6 +137,7 @@ impl PickerDelegate for OpenPathDelegate {
         } else {
             (query, String::new())
         };
+
         if dir == "" {
             #[cfg(not(target_os = "windows"))]
             {
@@ -171,6 +172,13 @@ impl PickerDelegate for OpenPathDelegate {
                 this.update(cx, |this, _| {
                     this.delegate.directory_state = Some(match paths {
                         Ok(mut paths) => {
+                            if dir == "/" {
+                                paths.push(DirectoryItem {
+                                    is_dir: true,
+                                    path: Default::default(),
+                                });
+                            }
+
                             paths.sort_by(|a, b| compare_paths((&a.path, true), (&b.path, true)));
                             let match_candidates = paths
                                 .iter()
@@ -309,12 +317,16 @@ impl PickerDelegate for OpenPathDelegate {
         let Some(candidate) = directory_state.match_candidates.get(*m) else {
             return;
         };
-        let result = Path::new(
-            self.lister
-                .resolve_tilde(&directory_state.path, cx)
-                .as_ref(),
-        )
-        .join(&candidate.path.string);
+        let result = if directory_state.path == "/" && candidate.path.string.is_empty() {
+            PathBuf::from("/")
+        } else {
+            Path::new(
+                self.lister
+                    .resolve_tilde(&directory_state.path, cx)
+                    .as_ref(),
+            )
+            .join(&candidate.path.string)
+        };
         if let Some(tx) = self.tx.take() {
             tx.send(Some(vec![result])).ok();
         }
@@ -355,7 +367,11 @@ impl PickerDelegate for OpenPathDelegate {
                 .inset(true)
                 .toggle_state(selected)
                 .child(HighlightedLabel::new(
-                    candidate.path.string.clone(),
+                    if directory_state.path == "/" {
+                        format!("/{}", candidate.path.string)
+                    } else {
+                        candidate.path.string.clone()
+                    },
                     highlight_positions,
                 )),
         )

crates/project/src/project.rs 🔗

@@ -3904,11 +3904,7 @@ impl Project {
         })
     }
 
-    pub fn resolve_abs_path(
-        &self,
-        path: &str,
-        cx: &mut Context<Self>,
-    ) -> Task<Option<ResolvedPath>> {
+    pub fn resolve_abs_path(&self, path: &str, cx: &App) -> Task<Option<ResolvedPath>> {
         if self.is_local() {
             let expanded = PathBuf::from(shellexpand::tilde(&path).into_owned());
             let fs = self.fs.clone();
@@ -5124,6 +5120,13 @@ impl ResolvedPath {
         }
     }
 
+    pub fn into_abs_path(self) -> Option<PathBuf> {
+        match self {
+            Self::AbsPath { path, .. } => Some(path),
+            _ => None,
+        }
+    }
+
     pub fn project_path(&self) -> Option<&ProjectPath> {
         match self {
             Self::ProjectPath { project_path, .. } => Some(&project_path),

crates/project/src/worktree_store.rs 🔗

@@ -262,7 +262,7 @@ impl WorktreeStore {
         if abs_path.starts_with("/~") {
             abs_path = abs_path[1..].to_string();
         }
-        if abs_path.is_empty() || abs_path == "/" {
+        if abs_path.is_empty() {
             abs_path = "~/".to_string();
         }
         cx.spawn(async move |this, cx| {

crates/project_panel/src/project_panel.rs 🔗

@@ -2855,13 +2855,7 @@ impl ProjectPanel {
                 }
                 let worktree_abs_path = worktree.read(cx).abs_path();
                 let (depth, path) = if Some(entry.entry) == worktree.read(cx).root_entry() {
-                    let Some(path_name) = worktree_abs_path
-                        .file_name()
-                        .with_context(|| {
-                            format!("Worktree abs path has no file name, root entry: {entry:?}")
-                        })
-                        .log_err()
-                    else {
+                    let Some(path_name) = worktree_abs_path.file_name() else {
                         continue;
                     };
                     let path = ArcCow::Borrowed(Path::new(path_name));

crates/recent_projects/src/remote_servers.rs 🔗

@@ -124,20 +124,20 @@ impl ProjectPicker {
         ix: usize,
         connection: SshConnectionOptions,
         project: Entity<Project>,
+        home_dir: PathBuf,
         workspace: WeakEntity<Workspace>,
         window: &mut Window,
         cx: &mut Context<RemoteServerProjects>,
     ) -> Entity<Self> {
         let (tx, rx) = oneshot::channel();
         let lister = project::DirectoryLister::Project(project.clone());
-        let query = lister.default_query(cx);
         let delegate = file_finder::OpenPathDelegate::new(tx, lister);
 
         let picker = cx.new(|cx| {
             let picker = Picker::uniform_list(delegate, window, cx)
                 .width(rems(34.))
                 .modal(false);
-            picker.set_query(query, window, cx);
+            picker.set_query(home_dir.to_string_lossy().to_string(), window, cx);
             picker
         });
         let connection_string = connection.connection_string().into();
@@ -345,6 +345,7 @@ impl RemoteServerProjects {
         ix: usize,
         connection_options: remote::SshConnectionOptions,
         project: Entity<Project>,
+        home_dir: PathBuf,
         window: &mut Window,
         cx: &mut Context<Self>,
         workspace: WeakEntity<Workspace>,
@@ -354,6 +355,7 @@ impl RemoteServerProjects {
             ix,
             connection_options,
             project,
+            home_dir,
             workspace,
             window,
             cx,
@@ -467,6 +469,7 @@ impl RemoteServerProjects {
         let connection_options = ssh_connection.into();
         workspace.update(cx, |_, cx| {
             cx.defer_in(window, move |workspace, window, cx| {
+                let app_state = workspace.app_state().clone();
                 workspace.toggle_modal(window, cx, |window, cx| {
                     SshConnectionModal::new(&connection_options, Vec::new(), window, cx)
                 });
@@ -489,44 +492,48 @@ impl RemoteServerProjects {
                 cx.spawn_in(window, async move |workspace, cx| {
                     let session = connect.await;
 
-                    workspace
-                        .update(cx, |workspace, cx| {
-                            if let Some(prompt) = workspace.active_modal::<SshConnectionModal>(cx) {
-                                prompt.update(cx, |prompt, cx| prompt.finished(cx))
-                            }
-                        })
-                        .ok();
+                    workspace.update(cx, |workspace, cx| {
+                        if let Some(prompt) = workspace.active_modal::<SshConnectionModal>(cx) {
+                            prompt.update(cx, |prompt, cx| prompt.finished(cx))
+                        }
+                    })?;
 
                     let Some(Some(session)) = session else {
-                        workspace
-                            .update_in(cx, |workspace, window, cx| {
-                                let weak = cx.entity().downgrade();
-                                workspace.toggle_modal(window, cx, |window, cx| {
-                                    RemoteServerProjects::new(window, cx, weak)
-                                });
-                            })
-                            .log_err();
-                        return;
+                        return workspace.update_in(cx, |workspace, window, cx| {
+                            let weak = cx.entity().downgrade();
+                            workspace.toggle_modal(window, cx, |window, cx| {
+                                RemoteServerProjects::new(window, cx, weak)
+                            });
+                        });
                     };
 
+                    let project = cx.update(|_, cx| {
+                        project::Project::ssh(
+                            session,
+                            app_state.client.clone(),
+                            app_state.node_runtime.clone(),
+                            app_state.user_store.clone(),
+                            app_state.languages.clone(),
+                            app_state.fs.clone(),
+                            cx,
+                        )
+                    })?;
+
+                    let home_dir = project
+                        .read_with(cx, |project, cx| project.resolve_abs_path("~", cx))?
+                        .await
+                        .and_then(|path| path.into_abs_path())
+                        .unwrap_or(PathBuf::from("/"));
+
                     workspace
                         .update_in(cx, |workspace, window, cx| {
-                            let app_state = workspace.app_state().clone();
                             let weak = cx.entity().downgrade();
-                            let project = project::Project::ssh(
-                                session,
-                                app_state.client.clone(),
-                                app_state.node_runtime.clone(),
-                                app_state.user_store.clone(),
-                                app_state.languages.clone(),
-                                app_state.fs.clone(),
-                                cx,
-                            );
                             workspace.toggle_modal(window, cx, |window, cx| {
                                 RemoteServerProjects::project_picker(
                                     ix,
                                     connection_options,
                                     project,
+                                    home_dir,
                                     window,
                                     cx,
                                     weak,
@@ -534,8 +541,9 @@ impl RemoteServerProjects {
                             });
                         })
                         .ok();
+                    Ok(())
                 })
-                .detach()
+                .detach();
             })
         })
     }

crates/worktree/src/worktree.rs 🔗

@@ -2648,7 +2648,7 @@ impl Snapshot {
     }
 
     pub fn root_entry(&self) -> Option<&Entry> {
-        self.entry_for_path("")
+        self.entries_by_path.first()
     }
 
     /// TODO: what's the difference between `root_dir` and `abs_path`?

crates/zed/src/zed/open_listener.rs 🔗

@@ -530,8 +530,8 @@ pub async fn derive_paths_with_position(
 
 #[cfg(test)]
 mod tests {
-    use std::sync::Arc;
-
+    use super::*;
+    use crate::zed::{open_listener::open_local_workspace, tests::init_test};
     use cli::{
         CliResponse,
         ipc::{self},
@@ -539,10 +539,33 @@ mod tests {
     use editor::Editor;
     use gpui::TestAppContext;
     use serde_json::json;
+    use std::sync::Arc;
     use util::path;
     use workspace::{AppState, Workspace};
 
-    use crate::zed::{open_listener::open_local_workspace, tests::init_test};
+    #[gpui::test]
+    fn test_parse_ssh_url(cx: &mut TestAppContext) {
+        let _app_state = init_test(cx);
+        cx.update(|cx| {
+            SshSettings::register(cx);
+        });
+        let request =
+            cx.update(|cx| OpenRequest::parse(vec!["ssh://me@localhost:/".into()], cx).unwrap());
+        assert_eq!(
+            request.ssh_connection.unwrap(),
+            SshConnectionOptions {
+                host: "localhost".into(),
+                username: Some("me".into()),
+                port: None,
+                password: None,
+                args: None,
+                port_forwards: None,
+                nickname: None,
+                upload_binary_over_ssh: false,
+            }
+        );
+        assert_eq!(request.open_paths, vec!["/"]);
+    }
 
     #[gpui::test]
     async fn test_open_workspace_with_directory(cx: &mut TestAppContext) {