workspace: Fix multiple remote projects not restoring on reconnect or restart and not visible in recent projects (#35398)

Smit Barmase created

Closes #33787

We were not updating SSH paths after initial project was created. Now we
update paths when worktrees are added/removed and serialize these
updated paths. This is separate from workspace because unlike local
paths, SSH paths are not part of the workspace table, but the SSH table
instead. We don't need to update SSH paths every time we serialize the
workspace.

<img width="400"
src="https://github.com/user-attachments/assets/9e1a9893-e08e-4ecf-8dab-1e9befced58b"
/>

Release Notes:

- Fixed issue where multiple remote folders in a project were lost on
reconnect, not restored on restart, and not visible in recent projects.

Change summary

crates/workspace/src/persistence.rs | 72 ++++++++++++++++++++++++++++++
crates/workspace/src/workspace.rs   | 71 ++++++++++++++++++++++++-----
2 files changed, 130 insertions(+), 13 deletions(-)

Detailed changes

crates/workspace/src/persistence.rs 🔗

@@ -939,6 +939,26 @@ impl WorkspaceDb {
         }
     }
 
+    query! {
+        pub async fn update_ssh_project_paths_query(ssh_project_id: u64, paths: String) -> Result<Option<SerializedSshProject>> {
+            UPDATE ssh_projects
+            SET paths = ?2
+            WHERE id = ?1
+            RETURNING id, host, port, paths, user
+        }
+    }
+
+    pub(crate) async fn update_ssh_project_paths(
+        &self,
+        ssh_project_id: SshProjectId,
+        new_paths: Vec<String>,
+    ) -> Result<SerializedSshProject> {
+        let paths = serde_json::to_string(&new_paths)?;
+        self.update_ssh_project_paths_query(ssh_project_id.0, paths)
+            .await?
+            .context("failed to update ssh project paths")
+    }
+
     query! {
         pub async fn next_id() -> Result<WorkspaceId> {
             INSERT INTO workspaces DEFAULT VALUES RETURNING workspace_id
@@ -2624,4 +2644,56 @@ mod tests {
 
         assert_eq!(workspace.center_group, new_workspace.center_group);
     }
+
+    #[gpui::test]
+    async fn test_update_ssh_project_paths() {
+        zlog::init_test();
+
+        let db = WorkspaceDb::open_test_db("test_update_ssh_project_paths").await;
+
+        let (host, port, initial_paths, user) = (
+            "example.com".to_string(),
+            Some(22_u16),
+            vec!["/home/user".to_string(), "/etc/nginx".to_string()],
+            Some("user".to_string()),
+        );
+
+        let project = db
+            .get_or_create_ssh_project(host.clone(), port, initial_paths.clone(), user.clone())
+            .await
+            .unwrap();
+
+        assert_eq!(project.host, host);
+        assert_eq!(project.paths, initial_paths);
+        assert_eq!(project.user, user);
+
+        let new_paths = vec![
+            "/home/user".to_string(),
+            "/etc/nginx".to_string(),
+            "/var/log".to_string(),
+            "/opt/app".to_string(),
+        ];
+
+        let updated_project = db
+            .update_ssh_project_paths(project.id, new_paths.clone())
+            .await
+            .unwrap();
+
+        assert_eq!(updated_project.id, project.id);
+        assert_eq!(updated_project.paths, new_paths);
+
+        let retrieved_project = db
+            .get_ssh_project(
+                host.clone(),
+                port,
+                serde_json::to_string(&new_paths).unwrap(),
+                user.clone(),
+            )
+            .await
+            .unwrap()
+            .unwrap();
+
+        assert_eq!(retrieved_project.id, project.id);
+        assert_eq!(retrieved_project.paths, new_paths);
+    }
 }

crates/workspace/src/workspace.rs 🔗

@@ -1094,7 +1094,8 @@ pub struct Workspace {
     _subscriptions: Vec<Subscription>,
     _apply_leader_updates: Task<Result<()>>,
     _observe_current_user: Task<Result<()>>,
-    _schedule_serialize: Option<Task<()>>,
+    _schedule_serialize_workspace: Option<Task<()>>,
+    _schedule_serialize_ssh_paths: Option<Task<()>>,
     pane_history_timestamp: Arc<AtomicUsize>,
     bounds: Bounds<Pixels>,
     pub centered_layout: bool,
@@ -1153,6 +1154,8 @@ impl Workspace {
 
                 project::Event::WorktreeRemoved(_) | project::Event::WorktreeAdded(_) => {
                     this.update_window_title(window, cx);
+                    this.update_ssh_paths(cx);
+                    this.serialize_ssh_paths(window, cx);
                     this.serialize_workspace(window, cx);
                     // This event could be triggered by `AddFolderToProject` or `RemoveFromProject`.
                     this.update_history(cx);
@@ -1420,7 +1423,8 @@ impl Workspace {
             app_state,
             _observe_current_user,
             _apply_leader_updates,
-            _schedule_serialize: None,
+            _schedule_serialize_workspace: None,
+            _schedule_serialize_ssh_paths: None,
             leader_updates_tx,
             _subscriptions: subscriptions,
             pane_history_timestamp,
@@ -5077,6 +5081,46 @@ impl Workspace {
         }
     }
 
+    fn update_ssh_paths(&mut self, cx: &App) {
+        let project = self.project().read(cx);
+        if !project.is_local() {
+            let paths: Vec<String> = project
+                .visible_worktrees(cx)
+                .map(|worktree| worktree.read(cx).abs_path().to_string_lossy().to_string())
+                .collect();
+            if let Some(ssh_project) = &mut self.serialized_ssh_project {
+                ssh_project.paths = paths;
+            }
+        }
+    }
+
+    fn serialize_ssh_paths(&mut self, window: &mut Window, cx: &mut Context<Workspace>) {
+        if self._schedule_serialize_ssh_paths.is_none() {
+            self._schedule_serialize_ssh_paths =
+                Some(cx.spawn_in(window, async move |this, cx| {
+                    cx.background_executor()
+                        .timer(SERIALIZATION_THROTTLE_TIME)
+                        .await;
+                    this.update_in(cx, |this, window, cx| {
+                        let task = if let Some(ssh_project) = &this.serialized_ssh_project {
+                            let ssh_project_id = ssh_project.id;
+                            let ssh_project_paths = ssh_project.paths.clone();
+                            window.spawn(cx, async move |_| {
+                                persistence::DB
+                                    .update_ssh_project_paths(ssh_project_id, ssh_project_paths)
+                                    .await
+                            })
+                        } else {
+                            Task::ready(Err(anyhow::anyhow!("No SSH project to serialize")))
+                        };
+                        task.detach();
+                        this._schedule_serialize_ssh_paths.take();
+                    })
+                    .log_err();
+                }));
+        }
+    }
+
     fn remove_panes(&mut self, member: Member, window: &mut Window, cx: &mut Context<Workspace>) {
         match member {
             Member::Axis(PaneAxis { members, .. }) => {
@@ -5120,17 +5164,18 @@ impl Workspace {
     }
 
     fn serialize_workspace(&mut self, window: &mut Window, cx: &mut Context<Self>) {
-        if self._schedule_serialize.is_none() {
-            self._schedule_serialize = Some(cx.spawn_in(window, async move |this, cx| {
-                cx.background_executor()
-                    .timer(Duration::from_millis(100))
-                    .await;
-                this.update_in(cx, |this, window, cx| {
-                    this.serialize_workspace_internal(window, cx).detach();
-                    this._schedule_serialize.take();
-                })
-                .log_err();
-            }));
+        if self._schedule_serialize_workspace.is_none() {
+            self._schedule_serialize_workspace =
+                Some(cx.spawn_in(window, async move |this, cx| {
+                    cx.background_executor()
+                        .timer(SERIALIZATION_THROTTLE_TIME)
+                        .await;
+                    this.update_in(cx, |this, window, cx| {
+                        this.serialize_workspace_internal(window, cx).detach();
+                        this._schedule_serialize_workspace.take();
+                    })
+                    .log_err();
+                }));
         }
     }