Workspace persistence for SSH projects (#17996)

Thorsten Ball and Bennet Bo Fenner created

TODOs:

- [x] Add tests to `workspace/src/persistence.rs`
- [x] Add a icon for ssh projects
- [x] Fix all `TODO` comments
- [x] Use `port` if it's passed in the ssh connection options

In next PRs:
- Make sure unsaved buffers are persisted/restored, along with other
items/layout
- Handle multiple paths/worktrees correctly


Release Notes:

- N/A

---------

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

Change summary

Cargo.lock                                    |   1 
crates/recent_projects/src/dev_servers.rs     |   7 
crates/recent_projects/src/recent_projects.rs | 108 ++++-
crates/recent_projects/src/ssh_connections.rs |  67 +--
crates/remote/src/ssh_session.rs              |   5 
crates/sqlez/src/bindable.rs                  |  16 
crates/sqlez/src/typed_statements.rs          |   2 
crates/workspace/Cargo.toml                   |   1 
crates/workspace/src/persistence.rs           | 374 +++++++++++++++++---
crates/workspace/src/persistence/model.rs     |  66 +++
crates/workspace/src/workspace.rs             |  80 ++++
crates/zed/src/main.rs                        |   6 
12 files changed, 592 insertions(+), 141 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -14096,6 +14096,7 @@ dependencies = [
  "parking_lot",
  "postage",
  "project",
+ "remote",
  "schemars",
  "serde",
  "serde_json",

crates/recent_projects/src/dev_servers.rs 🔗

@@ -39,7 +39,6 @@ use ui::{
     RadioWithLabel, Tooltip,
 };
 use ui_input::{FieldLabelLayout, TextField};
-use util::paths::PathWithPosition;
 use util::ResultExt;
 use workspace::notifications::NotifyResultExt;
 use workspace::OpenOptions;
@@ -987,11 +986,7 @@ impl DevServerProjects {
                 cx.spawn(|_, mut cx| async move {
                     let result = open_ssh_project(
                         server.into(),
-                        project
-                            .paths
-                            .into_iter()
-                            .map(|path| PathWithPosition::from_path(PathBuf::from(path)))
-                            .collect(),
+                        project.paths.into_iter().map(PathBuf::from).collect(),
                         app_state,
                         OpenOptions::default(),
                         &mut cx,

crates/recent_projects/src/recent_projects.rs 🔗

@@ -2,6 +2,7 @@ mod dev_servers;
 pub mod disconnected_overlay;
 mod ssh_connections;
 mod ssh_remotes;
+use remote::SshConnectionOptions;
 pub use ssh_connections::open_ssh_project;
 
 use client::{DevServerProjectId, ProjectId};
@@ -32,8 +33,8 @@ use ui::{
 };
 use util::{paths::PathExt, ResultExt};
 use workspace::{
-    AppState, CloseIntent, ModalView, SerializedWorkspaceLocation, Workspace, WorkspaceId,
-    WORKSPACE_DB,
+    AppState, CloseIntent, ModalView, OpenOptions, SerializedWorkspaceLocation, Workspace,
+    WorkspaceId, WORKSPACE_DB,
 };
 
 #[derive(PartialEq, Clone, Deserialize, Default)]
@@ -172,7 +173,7 @@ pub struct RecentProjectsDelegate {
     create_new_window: bool,
     // Flag to reset index when there is a new query vs not reset index when user delete an item
     reset_selected_match_index: bool,
-    has_any_dev_server_projects: bool,
+    has_any_non_local_projects: bool,
 }
 
 impl RecentProjectsDelegate {
@@ -185,16 +186,16 @@ impl RecentProjectsDelegate {
             create_new_window,
             render_paths,
             reset_selected_match_index: true,
-            has_any_dev_server_projects: false,
+            has_any_non_local_projects: false,
         }
     }
 
     pub fn set_workspaces(&mut self, workspaces: Vec<(WorkspaceId, SerializedWorkspaceLocation)>) {
         self.workspaces = workspaces;
-        self.has_any_dev_server_projects = self
+        self.has_any_non_local_projects = !self
             .workspaces
             .iter()
-            .any(|(_, location)| matches!(location, SerializedWorkspaceLocation::DevServer(_)));
+            .all(|(_, location)| matches!(location, SerializedWorkspaceLocation::Local(_, _)));
     }
 }
 impl EventEmitter<DismissEvent> for RecentProjectsDelegate {}
@@ -258,6 +259,23 @@ impl PickerDelegate for RecentProjectsDelegate {
                             dev_server_project.paths.join("")
                         )
                     }
+                    SerializedWorkspaceLocation::Ssh(ssh_project) => {
+                        format!(
+                            "{}{}{}{}",
+                            ssh_project.host,
+                            ssh_project
+                                .port
+                                .as_ref()
+                                .map(|port| port.to_string())
+                                .unwrap_or_default(),
+                            ssh_project.path,
+                            ssh_project
+                                .user
+                                .as_ref()
+                                .map(|user| user.to_string())
+                                .unwrap_or_default()
+                        )
+                    }
                 };
 
                 StringMatchCandidate::new(id, combined_string)
@@ -364,6 +382,33 @@ impl PickerDelegate for RecentProjectsDelegate {
                                 };
                                 open_dev_server_project(replace_current_window, dev_server_project.id, project_id, cx)
                         }
+                        SerializedWorkspaceLocation::Ssh(ssh_project) => {
+                            let app_state = workspace.app_state().clone();
+
+                            let replace_window = if replace_current_window {
+                                cx.window_handle().downcast::<Workspace>()
+                            } else {
+                                None
+                            };
+
+                            let open_options = OpenOptions {
+                                replace_window,
+                                ..Default::default()
+                            };
+
+                            let connection_options = SshConnectionOptions {
+                                host: ssh_project.host.clone(),
+                                username: ssh_project.user.clone(),
+                                port: ssh_project.port,
+                                password: None,
+                            };
+
+                            let paths = vec![PathBuf::from(ssh_project.path.clone())];
+
+                            cx.spawn(|_, mut cx| async move {
+                                open_ssh_project(connection_options, paths, app_state, open_options, &mut cx).await
+                            })
+                        }
                     }
                 }
                 })
@@ -392,7 +437,6 @@ impl PickerDelegate for RecentProjectsDelegate {
 
         let (_, location) = self.workspaces.get(hit.candidate_id)?;
 
-        let is_remote = matches!(location, SerializedWorkspaceLocation::DevServer(_));
         let dev_server_status =
             if let SerializedWorkspaceLocation::DevServer(dev_server_project) = location {
                 let store = dev_server_projects::Store::global(cx).read(cx);
@@ -416,6 +460,9 @@ 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::DevServer(dev_server_project) => {
                 Arc::new(vec![PathBuf::from(format!(
                     "{}:{}",
@@ -457,29 +504,34 @@ impl PickerDelegate for RecentProjectsDelegate {
                     h_flex()
                         .flex_grow()
                         .gap_3()
-                        .when(self.has_any_dev_server_projects, |this| {
-                            this.child(if is_remote {
-                                // if disabled, Color::Disabled
-                                let indicator_color = match dev_server_status {
-                                    Some(DevServerStatus::Online) => Color::Created,
-                                    Some(DevServerStatus::Offline) => Color::Hidden,
-                                    _ => unreachable!(),
-                                };
-                                IconWithIndicator::new(
-                                    Icon::new(IconName::Server).color(Color::Muted),
-                                    Some(Indicator::dot()),
-                                )
-                                .indicator_color(indicator_color)
-                                .indicator_border_color(if selected {
-                                    Some(cx.theme().colors().element_selected)
-                                } else {
-                                    None
-                                })
-                                .into_any_element()
-                            } else {
-                                Icon::new(IconName::Screen)
+                        .when(self.has_any_non_local_projects, |this| {
+                            this.child(match location {
+                                SerializedWorkspaceLocation::Local(_, _) => {
+                                    Icon::new(IconName::Screen)
+                                        .color(Color::Muted)
+                                        .into_any_element()
+                                }
+                                SerializedWorkspaceLocation::Ssh(_) => Icon::new(IconName::Screen)
                                     .color(Color::Muted)
+                                    .into_any_element(),
+                                SerializedWorkspaceLocation::DevServer(_) => {
+                                    let indicator_color = match dev_server_status {
+                                        Some(DevServerStatus::Online) => Color::Created,
+                                        Some(DevServerStatus::Offline) => Color::Hidden,
+                                        _ => unreachable!(),
+                                    };
+                                    IconWithIndicator::new(
+                                        Icon::new(IconName::Server).color(Color::Muted),
+                                        Some(Indicator::dot()),
+                                    )
+                                    .indicator_color(indicator_color)
+                                    .indicator_border_color(if selected {
+                                        Some(cx.theme().colors().element_selected)
+                                    } else {
+                                        None
+                                    })
                                     .into_any_element()
+                                }
                             })
                         })
                         .child({

crates/recent_projects/src/ssh_connections.rs 🔗

@@ -19,7 +19,6 @@ use ui::{
     h_flex, v_flex, FluentBuilder as _, Icon, IconName, IconSize, InteractiveElement, IntoElement,
     Label, LabelCommon, Styled, StyledExt as _, ViewContext, VisualContext, WindowContext,
 };
-use util::paths::PathWithPosition;
 use workspace::{AppState, ModalView, Workspace};
 
 #[derive(Deserialize)]
@@ -358,24 +357,29 @@ pub fn connect_over_ssh(
 
 pub async fn open_ssh_project(
     connection_options: SshConnectionOptions,
-    paths: Vec<PathWithPosition>,
+    paths: Vec<PathBuf>,
     app_state: Arc<AppState>,
-    _open_options: workspace::OpenOptions,
+    open_options: workspace::OpenOptions,
     cx: &mut AsyncAppContext,
 ) -> Result<()> {
     let options = cx.update(|cx| (app_state.build_window_options)(None, cx))?;
-    let window = cx.open_window(options, |cx| {
-        let project = project::Project::local(
-            app_state.client.clone(),
-            app_state.node_runtime.clone(),
-            app_state.user_store.clone(),
-            app_state.languages.clone(),
-            app_state.fs.clone(),
-            None,
-            cx,
-        );
-        cx.new_view(|cx| Workspace::new(None, project, app_state.clone(), cx))
-    })?;
+
+    let window = if let Some(window) = open_options.replace_window {
+        window
+    } else {
+        cx.open_window(options, |cx| {
+            let project = project::Project::local(
+                app_state.client.clone(),
+                app_state.node_runtime.clone(),
+                app_state.user_store.clone(),
+                app_state.languages.clone(),
+                app_state.fs.clone(),
+                None,
+                cx,
+            );
+            cx.new_view(|cx| Workspace::new(None, project, app_state.clone(), cx))
+        })?
+    };
 
     let result = window
         .update(cx, |workspace, cx| {
@@ -387,40 +391,17 @@ pub async fn open_ssh_project(
                 .read(cx)
                 .prompt
                 .clone();
-            connect_over_ssh(connection_options, ui, cx)
+            connect_over_ssh(connection_options.clone(), ui, cx)
         })?
         .await;
 
     if result.is_err() {
         window.update(cx, |_, cx| cx.remove_window()).ok();
     }
-
     let session = result?;
 
-    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,
-        )
-    })?;
-
-    for path in paths {
-        project
-            .update(cx, |project, cx| {
-                project.find_or_create_worktree(&path.path, true, cx)
-            })?
-            .await?;
-    }
-
-    window.update(cx, |_, cx| {
-        cx.replace_root_view(|cx| Workspace::new(None, project, app_state, cx))
-    })?;
-    window.update(cx, |_, cx| cx.activate_window())?;
-
-    Ok(())
+    cx.update(|cx| {
+        workspace::open_ssh_project(window, connection_options, session, app_state, paths, cx)
+    })?
+    .await
 }

crates/remote/src/ssh_session.rs 🔗

@@ -33,6 +33,11 @@ use std::{
 };
 use tempfile::TempDir;
 
+#[derive(
+    Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy, serde::Serialize, serde::Deserialize,
+)]
+pub struct SshProjectId(pub u64);
+
 #[derive(Clone)]
 pub struct SshSocket {
     connection_options: SshConnectionOptions,

crates/sqlez/src/bindable.rs 🔗

@@ -196,6 +196,22 @@ impl Column for u32 {
     }
 }
 
+impl StaticColumnCount for u16 {}
+impl Bind for u16 {
+    fn bind(&self, statement: &Statement, start_index: i32) -> Result<i32> {
+        (*self as i64)
+            .bind(statement, start_index)
+            .with_context(|| format!("Failed to bind usize at index {start_index}"))
+    }
+}
+
+impl Column for u16 {
+    fn column(statement: &mut Statement, start_index: i32) -> Result<(Self, i32)> {
+        let result = statement.column_int64(start_index)?;
+        Ok((result as u16, start_index + 1))
+    }
+}
+
 impl StaticColumnCount for usize {}
 impl Bind for usize {
     fn bind(&self, statement: &Statement, start_index: i32) -> Result<i32> {

crates/sqlez/src/typed_statements.rs 🔗

@@ -74,7 +74,7 @@ impl Connection {
     }
 
     /// Prepare a statement which takes a binding and selects a single row
-    /// from the database. WIll return none if no rows are returned and will
+    /// from the database. Will return none if no rows are returned and will
     /// error if more than 1 row is returned.
     ///
     /// Note: If there are multiple statements that depend upon each other

crates/workspace/Cargo.toml 🔗

@@ -51,6 +51,7 @@ postage.workspace = true
 project.workspace = true
 dev_server_projects.workspace = true
 task.workspace = true
+remote.workspace = true
 schemars.workspace = true
 serde.workspace = true
 serde_json.workspace = true

crates/workspace/src/persistence.rs 🔗

@@ -7,6 +7,7 @@ use client::DevServerProjectId;
 use db::{define_connection, query, sqlez::connection::Connection, sqlez_macros::sql};
 use gpui::{point, size, Axis, Bounds, WindowBounds, WindowId};
 
+use remote::ssh_session::SshProjectId;
 use sqlez::{
     bindable::{Bind, Column, StaticColumnCount},
     statement::Statement,
@@ -20,7 +21,7 @@ use crate::WorkspaceId;
 
 use model::{
     GroupId, LocalPaths, PaneId, SerializedItem, SerializedPane, SerializedPaneGroup,
-    SerializedWorkspace,
+    SerializedSshProject, SerializedWorkspace,
 };
 
 use self::model::{
@@ -354,7 +355,17 @@ define_connection! {
     ),
     sql!(
         ALTER TABLE panes ADD COLUMN pinned_count INTEGER DEFAULT 0;
-    )
+    ),
+    sql!(
+        CREATE TABLE ssh_projects (
+            id INTEGER PRIMARY KEY,
+            host TEXT NOT NULL,
+            port INTEGER,
+            path TEXT NOT NULL,
+            user TEXT
+        );
+        ALTER TABLE workspaces ADD COLUMN ssh_project_id INTEGER REFERENCES ssh_projects(id) ON DELETE CASCADE;
+    ),
     ];
 }
 
@@ -374,7 +385,6 @@ impl WorkspaceDb {
             workspace_id,
             local_paths,
             local_paths_order,
-            dev_server_project_id,
             window_bounds,
             display,
             centered_layout,
@@ -384,7 +394,6 @@ impl WorkspaceDb {
             WorkspaceId,
             Option<LocalPaths>,
             Option<LocalPathsOrder>,
-            Option<u64>,
             Option<SerializedWindowBounds>,
             Option<Uuid>,
             Option<bool>,
@@ -396,7 +405,6 @@ impl WorkspaceDb {
                     workspace_id,
                     local_paths,
                     local_paths_order,
-                    dev_server_project_id,
                     window_state,
                     window_x,
                     window_y,
@@ -422,28 +430,13 @@ impl WorkspaceDb {
             .warn_on_err()
             .flatten()?;
 
-        let location = if let Some(dev_server_project_id) = dev_server_project_id {
-            let dev_server_project: SerializedDevServerProject = self
-                .select_row_bound(sql! {
-                    SELECT id, path, dev_server_name
-                    FROM dev_server_projects
-                    WHERE id = ?
-                })
-                .and_then(|mut prepared_statement| (prepared_statement)(dev_server_project_id))
-                .context("No remote project found")
-                .warn_on_err()
-                .flatten()?;
-            SerializedWorkspaceLocation::DevServer(dev_server_project)
-        } else if let Some(local_paths) = local_paths {
-            match local_paths_order {
-                Some(order) => SerializedWorkspaceLocation::Local(local_paths, order),
-                None => {
-                    let order = LocalPathsOrder::default_for_paths(&local_paths);
-                    SerializedWorkspaceLocation::Local(local_paths, order)
-                }
+        let local_paths = local_paths?;
+        let location = match local_paths_order {
+            Some(order) => SerializedWorkspaceLocation::Local(local_paths, order),
+            None => {
+                let order = LocalPathsOrder::default_for_paths(&local_paths);
+                SerializedWorkspaceLocation::Local(local_paths, order)
             }
-        } else {
-            return None;
         };
 
         Some(SerializedWorkspace {
@@ -470,8 +463,6 @@ impl WorkspaceDb {
         // and we've grabbed the most recent workspace
         let (
             workspace_id,
-            local_paths,
-            local_paths_order,
             dev_server_project_id,
             window_bounds,
             display,
@@ -480,8 +471,6 @@ impl WorkspaceDb {
             window_id,
         ): (
             WorkspaceId,
-            Option<LocalPaths>,
-            Option<LocalPathsOrder>,
             Option<u64>,
             Option<SerializedWindowBounds>,
             Option<Uuid>,
@@ -492,8 +481,6 @@ impl WorkspaceDb {
             .select_row_bound(sql! {
                 SELECT
                     workspace_id,
-                    local_paths,
-                    local_paths_order,
                     dev_server_project_id,
                     window_state,
                     window_x,
@@ -520,29 +507,20 @@ impl WorkspaceDb {
             .warn_on_err()
             .flatten()?;
 
-        let location = if let Some(dev_server_project_id) = dev_server_project_id {
-            let dev_server_project: SerializedDevServerProject = self
-                .select_row_bound(sql! {
-                    SELECT id, path, dev_server_name
-                    FROM dev_server_projects
-                    WHERE id = ?
-                })
-                .and_then(|mut prepared_statement| (prepared_statement)(dev_server_project_id))
-                .context("No remote project found")
-                .warn_on_err()
-                .flatten()?;
-            SerializedWorkspaceLocation::DevServer(dev_server_project)
-        } else if let Some(local_paths) = local_paths {
-            match local_paths_order {
-                Some(order) => SerializedWorkspaceLocation::Local(local_paths, order),
-                None => {
-                    let order = LocalPathsOrder::default_for_paths(&local_paths);
-                    SerializedWorkspaceLocation::Local(local_paths, order)
-                }
-            }
-        } else {
-            return None;
-        };
+        let dev_server_project_id = dev_server_project_id?;
+
+        let dev_server_project: SerializedDevServerProject = self
+            .select_row_bound(sql! {
+                SELECT id, path, dev_server_name
+                FROM dev_server_projects
+                WHERE id = ?
+            })
+            .and_then(|mut prepared_statement| (prepared_statement)(dev_server_project_id))
+            .context("No remote project found")
+            .warn_on_err()
+            .flatten()?;
+
+        let location = SerializedWorkspaceLocation::DevServer(dev_server_project);
 
         Some(SerializedWorkspace {
             id: workspace_id,
@@ -560,6 +538,62 @@ impl WorkspaceDb {
         })
     }
 
+    pub(crate) fn workspace_for_ssh_project(
+        &self,
+        ssh_project: &SerializedSshProject,
+    ) -> Option<SerializedWorkspace> {
+        let (workspace_id, window_bounds, display, centered_layout, docks, window_id): (
+            WorkspaceId,
+            Option<SerializedWindowBounds>,
+            Option<Uuid>,
+            Option<bool>,
+            DockStructure,
+            Option<u64>,
+        ) = self
+            .select_row_bound(sql! {
+                SELECT
+                    workspace_id,
+                    window_state,
+                    window_x,
+                    window_y,
+                    window_width,
+                    window_height,
+                    display,
+                    centered_layout,
+                    left_dock_visible,
+                    left_dock_active_panel,
+                    left_dock_zoom,
+                    right_dock_visible,
+                    right_dock_active_panel,
+                    right_dock_zoom,
+                    bottom_dock_visible,
+                    bottom_dock_active_panel,
+                    bottom_dock_zoom,
+                    window_id
+                FROM workspaces
+                WHERE ssh_project_id = ?
+            })
+            .and_then(|mut prepared_statement| (prepared_statement)(ssh_project.id.0))
+            .context("No workspaces found")
+            .warn_on_err()
+            .flatten()?;
+
+        Some(SerializedWorkspace {
+            id: workspace_id,
+            location: SerializedWorkspaceLocation::Ssh(ssh_project.clone()),
+            center_group: self
+                .get_center_pane_group(workspace_id)
+                .context("Getting center group")
+                .log_err()?,
+            window_bounds,
+            centered_layout: centered_layout.unwrap_or(false),
+            display,
+            docks,
+            session_id: None,
+            window_id,
+        })
+    }
+
     /// Saves a workspace using the worktree roots. Will garbage collect any workspaces
     /// that used this workspace previously
     pub(crate) async fn save_workspace(&self, workspace: SerializedWorkspace) {
@@ -674,6 +708,49 @@ impl WorkspaceDb {
                             workspace.docks,
                         ))
                         .context("Updating workspace")?;
+                    },
+                    SerializedWorkspaceLocation::Ssh(ssh_project) => {
+                        conn.exec_bound(sql!(
+                            DELETE FROM workspaces WHERE ssh_project_id = ? AND workspace_id != ?
+                        ))?((ssh_project.id.0, workspace.id))
+                        .context("clearing out old locations")?;
+
+                        // Upsert
+                        conn.exec_bound(sql!(
+                            INSERT INTO workspaces(
+                                workspace_id,
+                                ssh_project_id,
+                                left_dock_visible,
+                                left_dock_active_panel,
+                                left_dock_zoom,
+                                right_dock_visible,
+                                right_dock_active_panel,
+                                right_dock_zoom,
+                                bottom_dock_visible,
+                                bottom_dock_active_panel,
+                                bottom_dock_zoom,
+                                timestamp
+                            )
+                            VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, CURRENT_TIMESTAMP)
+                            ON CONFLICT DO
+                            UPDATE SET
+                                ssh_project_id = ?2,
+                                left_dock_visible = ?3,
+                                left_dock_active_panel = ?4,
+                                left_dock_zoom = ?5,
+                                right_dock_visible = ?6,
+                                right_dock_active_panel = ?7,
+                                right_dock_zoom = ?8,
+                                bottom_dock_visible = ?9,
+                                bottom_dock_active_panel = ?10,
+                                bottom_dock_zoom = ?11,
+                                timestamp = CURRENT_TIMESTAMP
+                        ))?((
+                            workspace.id,
+                            ssh_project.id.0,
+                            workspace.docks,
+                        ))
+                        .context("Updating workspace")?;
                     }
                 }
 
@@ -688,6 +765,46 @@ impl WorkspaceDb {
         .await;
     }
 
+    pub(crate) async fn get_or_create_ssh_project(
+        &self,
+        host: String,
+        port: Option<u16>,
+        path: String,
+        user: Option<String>,
+    ) -> Result<SerializedSshProject> {
+        if let Some(project) = self
+            .get_ssh_project(host.clone(), port, path.clone(), user.clone())
+            .await?
+        {
+            Ok(project)
+        } else {
+            self.insert_ssh_project(host, port, path, 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
+            FROM ssh_projects
+            WHERE host IS ? AND port IS ? AND path 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>> {
+            INSERT INTO ssh_projects(
+                host,
+                port,
+                path,
+                user
+            ) VALUES (?1, ?2, ?3, ?4)
+            RETURNING id, host, port, path, user
+        }
+    }
+
     query! {
         pub async fn next_id() -> Result<WorkspaceId> {
             INSERT INTO workspaces DEFAULT VALUES RETURNING workspace_id
@@ -695,10 +812,12 @@ impl WorkspaceDb {
     }
 
     query! {
-        fn recent_workspaces() -> Result<Vec<(WorkspaceId, LocalPaths, LocalPathsOrder, Option<u64>)>> {
-            SELECT workspace_id, local_paths, local_paths_order, dev_server_project_id
+        fn recent_workspaces() -> Result<Vec<(WorkspaceId, LocalPaths, LocalPathsOrder, Option<u64>, Option<u64>)>> {
+            SELECT workspace_id, local_paths, local_paths_order, dev_server_project_id, ssh_project_id
             FROM workspaces
-            WHERE local_paths IS NOT NULL OR dev_server_project_id IS NOT NULL
+            WHERE local_paths IS NOT NULL
+                OR dev_server_project_id IS NOT NULL
+                OR ssh_project_id IS NOT NULL
             ORDER BY timestamp DESC
         }
     }
@@ -719,6 +838,13 @@ impl WorkspaceDb {
         }
     }
 
+    query! {
+        fn ssh_projects() -> Result<Vec<SerializedSshProject>> {
+            SELECT id, host, port, path, user
+            FROM ssh_projects
+        }
+    }
+
     pub(crate) fn last_window(
         &self,
     ) -> anyhow::Result<(Option<Uuid>, Option<SerializedWindowBounds>)> {
@@ -768,8 +894,11 @@ impl WorkspaceDb {
         let mut result = Vec::new();
         let mut delete_tasks = Vec::new();
         let dev_server_projects = self.dev_server_projects()?;
+        let ssh_projects = self.ssh_projects()?;
 
-        for (id, location, order, dev_server_project_id) in self.recent_workspaces()? {
+        for (id, location, order, dev_server_project_id, ssh_project_id) in
+            self.recent_workspaces()?
+        {
             if let Some(dev_server_project_id) = dev_server_project_id.map(DevServerProjectId) {
                 if let Some(dev_server_project) = dev_server_projects
                     .iter()
@@ -782,6 +911,15 @@ impl WorkspaceDb {
                 continue;
             }
 
+            if let Some(ssh_project_id) = ssh_project_id.map(SshProjectId) {
+                if let Some(ssh_project) = ssh_projects.iter().find(|rp| rp.id == ssh_project_id) {
+                    result.push((id, SerializedWorkspaceLocation::Ssh(ssh_project.clone())));
+                } else {
+                    delete_tasks.push(self.delete_workspace_by_id(id));
+                }
+                continue;
+            }
+
             if location.paths().iter().all(|path| path.exists())
                 && location.paths().iter().any(|path| path.is_dir())
             {
@@ -802,7 +940,9 @@ impl WorkspaceDb {
             .into_iter()
             .filter_map(|(_, location)| match location {
                 SerializedWorkspaceLocation::Local(local_paths, _) => Some(local_paths),
+                // Do not automatically reopen Dev Server and SSH workspaces
                 SerializedWorkspaceLocation::DevServer(_) => None,
+                SerializedWorkspaceLocation::Ssh(_) => None,
             })
             .next())
     }
@@ -1512,6 +1652,122 @@ mod tests {
         assert_eq!(have[3], LocalPaths::new([dir1.path().to_str().unwrap()]));
     }
 
+    #[gpui::test]
+    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) = (
+            "example.com".to_string(),
+            Some(22_u16),
+            "/home/user".to_string(),
+            Some("user".to_string()),
+        );
+
+        let project = db
+            .get_or_create_ssh_project(host.clone(), port, path.clone(), user.clone())
+            .await
+            .unwrap();
+
+        assert_eq!(project.host, host);
+        assert_eq!(project.path, path);
+        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())
+            .await
+            .unwrap();
+
+        assert_eq!(project.id, same_project.id);
+
+        // Test with different parameters
+        let (host2, path2, user2) = (
+            "otherexample.com".to_string(),
+            "/home/otheruser".to_string(),
+            Some("otheruser".to_string()),
+        );
+
+        let different_project = db
+            .get_or_create_ssh_project(host2.clone(), None, path2.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.user, user2);
+    }
+
+    #[gpui::test]
+    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) = (
+            "example.com".to_string(),
+            None,
+            "/home/user".to_string(),
+            None,
+        );
+
+        let project = db
+            .get_or_create_ssh_project(host.clone(), port, path.clone(), None)
+            .await
+            .unwrap();
+
+        assert_eq!(project.host, host);
+        assert_eq!(project.path, path);
+        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())
+            .await
+            .unwrap();
+
+        assert_eq!(project.id, same_project.id);
+    }
+
+    #[gpui::test]
+    async fn test_get_ssh_projects() {
+        let db = WorkspaceDb(open_test_db("test_get_ssh_projects").await);
+
+        let projects = vec![
+            (
+                "example.com".to_string(),
+                None,
+                "/home/user".to_string(),
+                None,
+            ),
+            (
+                "anotherexample.com".to_string(),
+                Some(123_u16),
+                "/home/user2".to_string(),
+                Some("user2".to_string()),
+            ),
+            (
+                "yetanother.com".to_string(),
+                Some(345_u16),
+                "/home/user3".to_string(),
+                None,
+            ),
+        ];
+
+        for (host, port, path, user) in projects.iter() {
+            let project = db
+                .get_or_create_ssh_project(host.clone(), *port, path.clone(), user.clone())
+                .await
+                .unwrap();
+
+            assert_eq!(&project.host, host);
+            assert_eq!(&project.port, port);
+            assert_eq!(&project.path, path);
+            assert_eq!(&project.user, user);
+        }
+
+        let stored_projects = db.ssh_projects().unwrap();
+        assert_eq!(stored_projects.len(), projects.len());
+    }
+
     #[gpui::test]
     async fn test_simple_split() {
         env_logger::try_init().ok();

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

@@ -11,6 +11,7 @@ use db::sqlez::{
 };
 use gpui::{AsyncWindowContext, Model, View, WeakView};
 use project::Project;
+use remote::ssh_session::SshProjectId;
 use serde::{Deserialize, Serialize};
 use std::{
     path::{Path, PathBuf},
@@ -20,6 +21,69 @@ use ui::SharedString;
 use util::ResultExt;
 use uuid::Uuid;
 
+#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
+pub struct SerializedSshProject {
+    pub id: SshProjectId,
+    pub host: String,
+    pub port: Option<u16>,
+    pub path: 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
+    }
+}
+
+impl StaticColumnCount for SerializedSshProject {
+    fn column_count() -> usize {
+        5
+    }
+}
+
+impl Bind for &SerializedSshProject {
+    fn bind(&self, statement: &Statement, start_index: i32) -> Result<i32> {
+        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)?;
+        statement.bind(&self.user, next_index)
+    }
+}
+
+impl Column for SerializedSshProject {
+    fn column(statement: &mut Statement, start_index: i32) -> Result<(Self, i32)> {
+        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 (user, _) = Option::<String>::column(statement, start_index + 4)?;
+
+        Ok((
+            Self {
+                id: SshProjectId(id as u64),
+                host,
+                port,
+                path,
+                user,
+            },
+            start_index + 5,
+        ))
+    }
+}
+
 #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
 pub struct SerializedDevServerProject {
     pub id: DevServerProjectId,
@@ -58,7 +122,6 @@ impl Column for LocalPaths {
     fn column(statement: &mut Statement, start_index: i32) -> Result<(Self, i32)> {
         let path_blob = statement.column_blob(start_index)?;
         let paths: Arc<Vec<PathBuf>> = if path_blob.is_empty() {
-            println!("path blog is empty");
             Default::default()
         } else {
             bincode::deserialize(path_blob).context("Bincode deserialization of paths failed")?
@@ -146,6 +209,7 @@ impl Column for SerializedDevServerProject {
 #[derive(Debug, PartialEq, Clone)]
 pub enum SerializedWorkspaceLocation {
     Local(LocalPaths, LocalPathsOrder),
+    Ssh(SerializedSshProject),
     DevServer(SerializedDevServerProject),
 }
 

crates/workspace/src/workspace.rs 🔗

@@ -49,15 +49,19 @@ use node_runtime::NodeRuntime;
 use notifications::{simple_message_notification::MessageNotification, NotificationHandle};
 pub use pane::*;
 pub use pane_group::*;
-use persistence::{model::SerializedWorkspace, SerializedWindowBounds, DB};
 pub use persistence::{
     model::{ItemId, LocalPaths, SerializedDevServerProject, SerializedWorkspaceLocation},
     WorkspaceDb, DB as WORKSPACE_DB,
 };
+use persistence::{
+    model::{SerializedSshProject, SerializedWorkspace},
+    SerializedWindowBounds, DB,
+};
 use postage::stream::Stream;
 use project::{
     DirectoryLister, Project, ProjectEntryId, ProjectPath, ResolvedPath, Worktree, WorktreeId,
 };
+use remote::{SshConnectionOptions, SshSession};
 use serde::Deserialize;
 use session::AppSession;
 use settings::Settings;
@@ -756,6 +760,7 @@ pub struct Workspace {
     render_disconnected_overlay:
         Option<Box<dyn Fn(&mut Self, &mut ViewContext<Self>) -> AnyElement>>,
     serializable_items_tx: UnboundedSender<Box<dyn SerializableItemHandle>>,
+    serialized_ssh_project: Option<SerializedSshProject>,
     _items_serializer: Task<Result<()>>,
     session_id: Option<String>,
 }
@@ -1054,6 +1059,7 @@ impl Workspace {
             serializable_items_tx,
             _items_serializer,
             session_id: Some(session_id),
+            serialized_ssh_project: None,
         }
     }
 
@@ -1440,6 +1446,10 @@ impl Workspace {
         self.on_prompt_for_open_path = Some(prompt)
     }
 
+    pub fn set_serialized_ssh_project(&mut self, serialized_ssh_project: SerializedSshProject) {
+        self.serialized_ssh_project = Some(serialized_ssh_project);
+    }
+
     pub fn set_render_disconnected_overlay(
         &mut self,
         render: impl Fn(&mut Self, &mut ViewContext<Self>) -> AnyElement + 'static,
@@ -4097,7 +4107,9 @@ impl Workspace {
             }
         }
 
-        let location = if let Some(local_paths) = self.local_paths(cx) {
+        let location = if let Some(ssh_project) = &self.serialized_ssh_project {
+            Some(SerializedWorkspaceLocation::Ssh(ssh_project.clone()))
+        } else if let Some(local_paths) = self.local_paths(cx) {
             if !local_paths.is_empty() {
                 Some(SerializedWorkspaceLocation::from_local_paths(local_paths))
             } else {
@@ -5476,6 +5488,70 @@ pub fn join_hosted_project(
     })
 }
 
+pub fn open_ssh_project(
+    window: WindowHandle<Workspace>,
+    connection_options: SshConnectionOptions,
+    session: Arc<SshSession>,
+    app_state: Arc<AppState>,
+    paths: Vec<PathBuf>,
+    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(),
+                connection_options.username.clone(),
+            )
+            .await?;
+
+        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,
+            )
+        })?;
+
+        for path in paths {
+            project
+                .update(&mut cx, |project, cx| {
+                    project.find_or_create_worktree(&path, true, cx)
+                })?
+                .await?;
+        }
+
+        let serialized_workspace =
+            persistence::DB.workspace_for_ssh_project(&serialized_ssh_project);
+
+        let workspace_id =
+            if let Some(workspace_id) = serialized_workspace.map(|workspace| workspace.id) {
+                workspace_id
+            } else {
+                persistence::DB.next_id().await?
+            };
+
+        cx.update_window(window.into(), |_, cx| {
+            cx.replace_root_view(|cx| {
+                let mut workspace =
+                    Workspace::new(Some(workspace_id), project, app_state.clone(), cx);
+                workspace.set_serialized_ssh_project(serialized_ssh_project);
+                workspace
+            });
+        })?;
+
+        window.update(&mut cx, |_, cx| cx.activate_window())
+    })
+}
+
 pub fn join_dev_server_project(
     dev_server_project_id: DevServerProjectId,
     project_id: ProjectId,

crates/zed/src/main.rs 🔗

@@ -667,7 +667,11 @@ fn handle_open_request(
         cx.spawn(|mut cx| async move {
             open_ssh_project(
                 connection_info,
-                request.open_paths,
+                request
+                    .open_paths
+                    .into_iter()
+                    .map(|path| path.path)
+                    .collect::<Vec<_>>(),
                 app_state,
                 workspace::OpenOptions::default(),
                 &mut cx,