ssh project: Handle multiple paths and worktrees correctly (#18277)

Thorsten Ball and Bennet created

This makes SSH projects work with `ssh_connections` that have multiple
paths:

```json
{
  "ssh_connections": [
    {
      "host": "127.0.0.1",
      "projects": [
        {
          "paths": [
            "/Users/thorstenball/work/projs/go-proj",
            "/Users/thorstenball/work/projs/rust-proj"
          ]
        }
      ]
    }
  ]
}
```

@ConradIrwin @mikayla-maki since this wasn't really released yet, we
didn't create a full-on migration, so old ssh projects that were already
serialized need to either be manually deleted from the database, or the
whole local DB wiped.

Release Notes:

- N/A

---------

Co-authored-by: Bennet <bennet@zed.dev>

Change summary

crates/recent_projects/src/recent_projects.rs |  8 -
crates/workspace/src/persistence.rs           | 64 +++++++++++---------
crates/workspace/src/persistence/model.rs     | 42 ++++++++-----
crates/workspace/src/workspace.rs             |  8 +-
4 files changed, 66 insertions(+), 56 deletions(-)

Detailed changes

crates/recent_projects/src/recent_projects.rs 🔗

@@ -268,7 +268,7 @@ impl PickerDelegate for RecentProjectsDelegate {
                                 .as_ref()
                                 .map(|port| port.to_string())
                                 .unwrap_or_default(),
-                            ssh_project.path,
+                            ssh_project.paths.join(","),
                             ssh_project
                                 .user
                                 .as_ref()
@@ -403,7 +403,7 @@ impl PickerDelegate for RecentProjectsDelegate {
                                 password: None,
                             };
 
-                            let paths = vec![PathBuf::from(ssh_project.path.clone())];
+                            let paths = ssh_project.paths.iter().map(PathBuf::from).collect();
 
                             cx.spawn(|_, mut cx| async move {
                                 open_ssh_project(connection_options, paths, app_state, open_options, &mut cx).await
@@ -460,9 +460,7 @@ impl PickerDelegate for RecentProjectsDelegate {
                     .filter_map(|i| paths.paths().get(*i).cloned())
                     .collect(),
             ),
-            SerializedWorkspaceLocation::Ssh(ssh_project) => {
-                Arc::new(vec![PathBuf::from(ssh_project.ssh_url())])
-            }
+            SerializedWorkspaceLocation::Ssh(ssh_project) => Arc::new(ssh_project.ssh_urls()),
             SerializedWorkspaceLocation::DevServer(dev_server_project) => {
                 Arc::new(vec![PathBuf::from(format!(
                     "{}:{}",

crates/workspace/src/persistence.rs 🔗

@@ -366,6 +366,9 @@ define_connection! {
         );
         ALTER TABLE workspaces ADD COLUMN ssh_project_id INTEGER REFERENCES ssh_projects(id) ON DELETE CASCADE;
     ),
+    sql!(
+        ALTER TABLE ssh_projects RENAME COLUMN path TO paths;
+    ),
     ];
 }
 
@@ -769,39 +772,40 @@ impl WorkspaceDb {
         &self,
         host: String,
         port: Option<u16>,
-        path: String,
+        paths: Vec<String>,
         user: Option<String>,
     ) -> Result<SerializedSshProject> {
+        let paths = serde_json::to_string(&paths)?;
         if let Some(project) = self
-            .get_ssh_project(host.clone(), port, path.clone(), user.clone())
+            .get_ssh_project(host.clone(), port, paths.clone(), user.clone())
             .await?
         {
             Ok(project)
         } else {
-            self.insert_ssh_project(host, port, path, user)
+            self.insert_ssh_project(host, port, paths, user)
                 .await?
                 .ok_or_else(|| anyhow!("failed to insert ssh project"))
         }
     }
 
     query! {
-        async fn get_ssh_project(host: String, port: Option<u16>, path: String, user: Option<String>) -> Result<Option<SerializedSshProject>> {
-            SELECT id, host, port, path, user
+        async fn get_ssh_project(host: String, port: Option<u16>, paths: String, user: Option<String>) -> Result<Option<SerializedSshProject>> {
+            SELECT id, host, port, paths, user
             FROM ssh_projects
-            WHERE host IS ? AND port IS ? AND path IS ? AND user IS ?
+            WHERE host IS ? AND port IS ? AND paths IS ? AND user IS ?
             LIMIT 1
         }
     }
 
     query! {
-        async fn insert_ssh_project(host: String, port: Option<u16>, path: String, user: Option<String>) -> Result<Option<SerializedSshProject>> {
+        async fn insert_ssh_project(host: String, port: Option<u16>, paths: String, user: Option<String>) -> Result<Option<SerializedSshProject>> {
             INSERT INTO ssh_projects(
                 host,
                 port,
-                path,
+                paths,
                 user
             ) VALUES (?1, ?2, ?3, ?4)
-            RETURNING id, host, port, path, user
+            RETURNING id, host, port, paths, user
         }
     }
 
@@ -840,7 +844,7 @@ impl WorkspaceDb {
 
     query! {
         fn ssh_projects() -> Result<Vec<SerializedSshProject>> {
-            SELECT id, host, port, path, user
+            SELECT id, host, port, paths, user
             FROM ssh_projects
         }
     }
@@ -1656,45 +1660,45 @@ mod tests {
     async fn test_get_or_create_ssh_project() {
         let db = WorkspaceDb(open_test_db("test_get_or_create_ssh_project").await);
 
-        let (host, port, path, user) = (
+        let (host, port, paths, user) = (
             "example.com".to_string(),
             Some(22_u16),
-            "/home/user".to_string(),
+            vec!["/home/user".to_string(), "/etc/nginx".to_string()],
             Some("user".to_string()),
         );
 
         let project = db
-            .get_or_create_ssh_project(host.clone(), port, path.clone(), user.clone())
+            .get_or_create_ssh_project(host.clone(), port, paths.clone(), user.clone())
             .await
             .unwrap();
 
         assert_eq!(project.host, host);
-        assert_eq!(project.path, path);
+        assert_eq!(project.paths, paths);
         assert_eq!(project.user, user);
 
         // Test that calling the function again with the same parameters returns the same project
         let same_project = db
-            .get_or_create_ssh_project(host.clone(), port, path.clone(), user.clone())
+            .get_or_create_ssh_project(host.clone(), port, paths.clone(), user.clone())
             .await
             .unwrap();
 
         assert_eq!(project.id, same_project.id);
 
         // Test with different parameters
-        let (host2, path2, user2) = (
+        let (host2, paths2, user2) = (
             "otherexample.com".to_string(),
-            "/home/otheruser".to_string(),
+            vec!["/home/otheruser".to_string()],
             Some("otheruser".to_string()),
         );
 
         let different_project = db
-            .get_or_create_ssh_project(host2.clone(), None, path2.clone(), user2.clone())
+            .get_or_create_ssh_project(host2.clone(), None, paths2.clone(), user2.clone())
             .await
             .unwrap();
 
         assert_ne!(project.id, different_project.id);
         assert_eq!(different_project.host, host2);
-        assert_eq!(different_project.path, path2);
+        assert_eq!(different_project.paths, paths2);
         assert_eq!(different_project.user, user2);
     }
 
@@ -1702,25 +1706,25 @@ mod tests {
     async fn test_get_or_create_ssh_project_with_null_user() {
         let db = WorkspaceDb(open_test_db("test_get_or_create_ssh_project_with_null_user").await);
 
-        let (host, port, path, user) = (
+        let (host, port, paths, user) = (
             "example.com".to_string(),
             None,
-            "/home/user".to_string(),
+            vec!["/home/user".to_string()],
             None,
         );
 
         let project = db
-            .get_or_create_ssh_project(host.clone(), port, path.clone(), None)
+            .get_or_create_ssh_project(host.clone(), port, paths.clone(), None)
             .await
             .unwrap();
 
         assert_eq!(project.host, host);
-        assert_eq!(project.path, path);
+        assert_eq!(project.paths, paths);
         assert_eq!(project.user, None);
 
         // Test that calling the function again with the same parameters returns the same project
         let same_project = db
-            .get_or_create_ssh_project(host.clone(), port, path.clone(), user.clone())
+            .get_or_create_ssh_project(host.clone(), port, paths.clone(), user.clone())
             .await
             .unwrap();
 
@@ -1735,32 +1739,32 @@ mod tests {
             (
                 "example.com".to_string(),
                 None,
-                "/home/user".to_string(),
+                vec!["/home/user".to_string()],
                 None,
             ),
             (
                 "anotherexample.com".to_string(),
                 Some(123_u16),
-                "/home/user2".to_string(),
+                vec!["/home/user2".to_string()],
                 Some("user2".to_string()),
             ),
             (
                 "yetanother.com".to_string(),
                 Some(345_u16),
-                "/home/user3".to_string(),
+                vec!["/home/user3".to_string(), "/proc/1234/exe".to_string()],
                 None,
             ),
         ];
 
-        for (host, port, path, user) in projects.iter() {
+        for (host, port, paths, user) in projects.iter() {
             let project = db
-                .get_or_create_ssh_project(host.clone(), *port, path.clone(), user.clone())
+                .get_or_create_ssh_project(host.clone(), *port, paths.clone(), user.clone())
                 .await
                 .unwrap();
 
             assert_eq!(&project.host, host);
             assert_eq!(&project.port, port);
-            assert_eq!(&project.path, path);
+            assert_eq!(&project.paths, paths);
             assert_eq!(&project.user, user);
         }
 

crates/workspace/src/persistence/model.rs 🔗

@@ -26,24 +26,29 @@ pub struct SerializedSshProject {
     pub id: SshProjectId,
     pub host: String,
     pub port: Option<u16>,
-    pub path: String,
+    pub paths: Vec<String>,
     pub user: Option<String>,
 }
 
 impl SerializedSshProject {
-    pub fn ssh_url(&self) -> String {
-        let mut result = String::from("ssh://");
-        if let Some(user) = &self.user {
-            result.push_str(user);
-            result.push('@');
-        }
-        result.push_str(&self.host);
-        if let Some(port) = &self.port {
-            result.push(':');
-            result.push_str(&port.to_string());
-        }
-        result.push_str(&self.path);
-        result
+    pub fn ssh_urls(&self) -> Vec<PathBuf> {
+        self.paths
+            .iter()
+            .map(|path| {
+                let mut result = String::new();
+                if let Some(user) = &self.user {
+                    result.push_str(user);
+                    result.push('@');
+                }
+                result.push_str(&self.host);
+                if let Some(port) = &self.port {
+                    result.push(':');
+                    result.push_str(&port.to_string());
+                }
+                result.push_str(path);
+                PathBuf::from(result)
+            })
+            .collect()
     }
 }
 
@@ -58,7 +63,8 @@ impl Bind for &SerializedSshProject {
         let next_index = statement.bind(&self.id.0, start_index)?;
         let next_index = statement.bind(&self.host, next_index)?;
         let next_index = statement.bind(&self.port, next_index)?;
-        let next_index = statement.bind(&self.path, next_index)?;
+        let raw_paths = serde_json::to_string(&self.paths)?;
+        let next_index = statement.bind(&raw_paths, next_index)?;
         statement.bind(&self.user, next_index)
     }
 }
@@ -68,7 +74,9 @@ impl Column for SerializedSshProject {
         let id = statement.column_int64(start_index)?;
         let host = statement.column_text(start_index + 1)?.to_string();
         let (port, _) = Option::<u16>::column(statement, start_index + 2)?;
-        let path = statement.column_text(start_index + 3)?.to_string();
+        let raw_paths = statement.column_text(start_index + 3)?.to_string();
+        let paths: Vec<String> = serde_json::from_str(&raw_paths)?;
+
         let (user, _) = Option::<String>::column(statement, start_index + 4)?;
 
         Ok((
@@ -76,7 +84,7 @@ impl Column for SerializedSshProject {
                 id: SshProjectId(id as u64),
                 host,
                 port,
-                path,
+                paths,
                 user,
             },
             start_index + 5,

crates/workspace/src/workspace.rs 🔗

@@ -5516,14 +5516,14 @@ pub fn open_ssh_project(
     cx: &mut AppContext,
 ) -> Task<Result<()>> {
     cx.spawn(|mut cx| async move {
-        // TODO: Handle multiple paths
-        let path = paths.iter().next().cloned().unwrap_or_default();
-
         let serialized_ssh_project = persistence::DB
             .get_or_create_ssh_project(
                 connection_options.host.clone(),
                 connection_options.port,
-                path.to_string_lossy().to_string(),
+                paths
+                    .iter()
+                    .map(|path| path.to_string_lossy().to_string())
+                    .collect::<Vec<_>>(),
                 connection_options.username.clone(),
             )
             .await?;