Restructure persistence of remote workspaces to make room for WSL and other non-ssh remote projects (#36714)

Max Brunsfeld and Mikayla Maki created

This is another pure refactor, to prepare for adding direct WSL support.

###  Todo

* [x] Represent `paths` in the same way for all workspaces, instead of
having a completely separate SSH representation
* [x] Adjust sqlite tables
    * [x] `ssh_projects` -> `ssh_connections` (drop paths)
    * [x] `workspaces.local_paths` -> `paths`
    * [x] remove duplicate path columns on `workspaces`
* [x] Add migrations for backward-compatibility

Release Notes:

- N/A

---------

Co-authored-by: Mikayla Maki <mikayla.c.maki@gmail.com>

Change summary

Cargo.lock                                         |   2 
crates/client/src/user.rs                          |   5 
crates/recent_projects/src/disconnected_overlay.rs |  14 
crates/recent_projects/src/recent_projects.rs      |  62 
crates/workspace/Cargo.toml                        |   2 
crates/workspace/src/history_manager.rs            |  18 
crates/workspace/src/path_list.rs                  | 121 ++
crates/workspace/src/persistence.rs                | 725 ++++++++-------
crates/workspace/src/persistence/model.rs          | 305 ------
crates/workspace/src/workspace.rs                  | 182 +--
crates/zed/src/main.rs                             |  15 
crates/zed/src/zed/open_listener.rs                |  17 
12 files changed, 619 insertions(+), 849 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -19829,7 +19829,6 @@ dependencies = [
  "any_vec",
  "anyhow",
  "async-recursion",
- "bincode",
  "call",
  "client",
  "clock",
@@ -19848,6 +19847,7 @@ dependencies = [
  "node_runtime",
  "parking_lot",
  "postage",
+ "pretty_assertions",
  "project",
  "remote",
  "schemars",

crates/client/src/user.rs 🔗

@@ -46,11 +46,6 @@ impl ProjectId {
     }
 }
 
-#[derive(
-    Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy, serde::Serialize, serde::Deserialize,
-)]
-pub struct DevServerProjectId(pub u64);
-
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
 pub struct ParticipantIndex(pub u32);
 

crates/recent_projects/src/disconnected_overlay.rs 🔗

@@ -1,5 +1,3 @@
-use std::path::PathBuf;
-
 use gpui::{ClickEvent, DismissEvent, EventEmitter, FocusHandle, Focusable, Render, WeakEntity};
 use project::project_settings::ProjectSettings;
 use remote::SshConnectionOptions;
@@ -103,17 +101,17 @@ impl DisconnectedOverlay {
             return;
         };
 
-        let Some(ssh_project) = workspace.read(cx).serialized_ssh_project() else {
-            return;
-        };
-
         let Some(window_handle) = window.window_handle().downcast::<Workspace>() else {
             return;
         };
 
         let app_state = workspace.read(cx).app_state().clone();
-
-        let paths = ssh_project.paths.iter().map(PathBuf::from).collect();
+        let paths = workspace
+            .read(cx)
+            .root_paths(cx)
+            .iter()
+            .map(|path| path.to_path_buf())
+            .collect();
 
         cx.spawn_in(window, async move |_, cx| {
             open_ssh_project(

crates/recent_projects/src/recent_projects.rs 🔗

@@ -19,15 +19,12 @@ use picker::{
 pub use remote_servers::RemoteServerProjects;
 use settings::Settings;
 pub use ssh_connections::SshSettings;
-use std::{
-    path::{Path, PathBuf},
-    sync::Arc,
-};
+use std::{path::Path, sync::Arc};
 use ui::{KeyBinding, ListItem, ListItemSpacing, Tooltip, prelude::*, tooltip_container};
 use util::{ResultExt, paths::PathExt};
 use workspace::{
-    CloseIntent, HistoryManager, ModalView, OpenOptions, SerializedWorkspaceLocation, WORKSPACE_DB,
-    Workspace, WorkspaceId, with_active_or_new_workspace,
+    CloseIntent, HistoryManager, ModalView, OpenOptions, PathList, SerializedWorkspaceLocation,
+    WORKSPACE_DB, Workspace, WorkspaceId, with_active_or_new_workspace,
 };
 use zed_actions::{OpenRecent, OpenRemote};
 
@@ -154,7 +151,7 @@ impl Render for RecentProjects {
 
 pub struct RecentProjectsDelegate {
     workspace: WeakEntity<Workspace>,
-    workspaces: Vec<(WorkspaceId, SerializedWorkspaceLocation)>,
+    workspaces: Vec<(WorkspaceId, SerializedWorkspaceLocation, PathList)>,
     selected_match_index: usize,
     matches: Vec<StringMatch>,
     render_paths: bool,
@@ -178,12 +175,15 @@ impl RecentProjectsDelegate {
         }
     }
 
-    pub fn set_workspaces(&mut self, workspaces: Vec<(WorkspaceId, SerializedWorkspaceLocation)>) {
+    pub fn set_workspaces(
+        &mut self,
+        workspaces: Vec<(WorkspaceId, SerializedWorkspaceLocation, PathList)>,
+    ) {
         self.workspaces = workspaces;
         self.has_any_non_local_projects = !self
             .workspaces
             .iter()
-            .all(|(_, location)| matches!(location, SerializedWorkspaceLocation::Local(_, _)));
+            .all(|(_, location, _)| matches!(location, SerializedWorkspaceLocation::Local));
     }
 }
 impl EventEmitter<DismissEvent> for RecentProjectsDelegate {}
@@ -236,15 +236,14 @@ impl PickerDelegate for RecentProjectsDelegate {
             .workspaces
             .iter()
             .enumerate()
-            .filter(|(_, (id, _))| !self.is_current_workspace(*id, cx))
-            .map(|(id, (_, location))| {
-                let combined_string = location
-                    .sorted_paths()
+            .filter(|(_, (id, _, _))| !self.is_current_workspace(*id, cx))
+            .map(|(id, (_, _, paths))| {
+                let combined_string = paths
+                    .paths()
                     .iter()
                     .map(|path| path.compact().to_string_lossy().into_owned())
                     .collect::<Vec<_>>()
                     .join("");
-
                 StringMatchCandidate::new(id, &combined_string)
             })
             .collect::<Vec<_>>();
@@ -279,7 +278,7 @@ impl PickerDelegate for RecentProjectsDelegate {
             .get(self.selected_index())
             .zip(self.workspace.upgrade())
         {
-            let (candidate_workspace_id, candidate_workspace_location) =
+            let (candidate_workspace_id, candidate_workspace_location, candidate_workspace_paths) =
                 &self.workspaces[selected_match.candidate_id];
             let replace_current_window = if self.create_new_window {
                 secondary
@@ -292,8 +291,8 @@ impl PickerDelegate for RecentProjectsDelegate {
                         Task::ready(Ok(()))
                     } else {
                         match candidate_workspace_location {
-                            SerializedWorkspaceLocation::Local(paths, _) => {
-                                let paths = paths.paths().to_vec();
+                            SerializedWorkspaceLocation::Local => {
+                                let paths = candidate_workspace_paths.paths().to_vec();
                                 if replace_current_window {
                                     cx.spawn_in(window, async move |workspace, cx| {
                                         let continue_replacing = workspace
@@ -321,7 +320,7 @@ impl PickerDelegate for RecentProjectsDelegate {
                                     workspace.open_workspace_for_paths(false, paths, window, cx)
                                 }
                             }
-                            SerializedWorkspaceLocation::Ssh(ssh_project) => {
+                            SerializedWorkspaceLocation::Ssh(connection) => {
                                 let app_state = workspace.app_state().clone();
 
                                 let replace_window = if replace_current_window {
@@ -337,12 +336,12 @@ impl PickerDelegate for RecentProjectsDelegate {
 
                                 let connection_options = SshSettings::get_global(cx)
                                     .connection_options_for(
-                                        ssh_project.host.clone(),
-                                        ssh_project.port,
-                                        ssh_project.user.clone(),
+                                        connection.host.clone(),
+                                        connection.port,
+                                        connection.user.clone(),
                                     );
 
-                                let paths = ssh_project.paths.iter().map(PathBuf::from).collect();
+                                let paths = candidate_workspace_paths.paths().to_vec();
 
                                 cx.spawn_in(window, async move |_, cx| {
                                     open_ssh_project(
@@ -383,12 +382,12 @@ impl PickerDelegate for RecentProjectsDelegate {
     ) -> Option<Self::ListItem> {
         let hit = self.matches.get(ix)?;
 
-        let (_, location) = self.workspaces.get(hit.candidate_id)?;
+        let (_, location, paths) = self.workspaces.get(hit.candidate_id)?;
 
         let mut path_start_offset = 0;
 
-        let (match_labels, paths): (Vec<_>, Vec<_>) = location
-            .sorted_paths()
+        let (match_labels, paths): (Vec<_>, Vec<_>) = paths
+            .paths()
             .iter()
             .map(|p| p.compact())
             .map(|path| {
@@ -416,11 +415,9 @@ impl PickerDelegate for RecentProjectsDelegate {
                         .gap_3()
                         .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::Local => Icon::new(IconName::Screen)
+                                    .color(Color::Muted)
+                                    .into_any_element(),
                                 SerializedWorkspaceLocation::Ssh(_) => Icon::new(IconName::Server)
                                     .color(Color::Muted)
                                     .into_any_element(),
@@ -568,7 +565,7 @@ impl RecentProjectsDelegate {
         cx: &mut Context<Picker<Self>>,
     ) {
         if let Some(selected_match) = self.matches.get(ix) {
-            let (workspace_id, _) = self.workspaces[selected_match.candidate_id];
+            let (workspace_id, _, _) = self.workspaces[selected_match.candidate_id];
             cx.spawn_in(window, async move |this, cx| {
                 let _ = WORKSPACE_DB.delete_workspace_by_id(workspace_id).await;
                 let workspaces = WORKSPACE_DB
@@ -707,7 +704,8 @@ mod tests {
                     }];
                     delegate.set_workspaces(vec![(
                         WorkspaceId::default(),
-                        SerializedWorkspaceLocation::from_local_paths(vec![path!("/test/path/")]),
+                        SerializedWorkspaceLocation::Local,
+                        PathList::new(&[path!("/test/path")]),
                     )]);
                 });
             })

crates/workspace/Cargo.toml 🔗

@@ -29,7 +29,6 @@ test-support = [
 any_vec.workspace = true
 anyhow.workspace = true
 async-recursion.workspace = true
-bincode.workspace = true
 call.workspace = true
 client.workspace = true
 clock.workspace = true
@@ -80,5 +79,6 @@ project = { workspace = true, features = ["test-support"] }
 session = { workspace = true, features = ["test-support"] }
 settings = { workspace = true, features = ["test-support"] }
 http_client = { workspace = true, features = ["test-support"] }
+pretty_assertions.workspace = true
 tempfile.workspace = true
 zlog.workspace = true

crates/workspace/src/history_manager.rs 🔗

@@ -5,7 +5,9 @@ use smallvec::SmallVec;
 use ui::App;
 use util::{ResultExt, paths::PathExt};
 
-use crate::{NewWindow, SerializedWorkspaceLocation, WORKSPACE_DB, WorkspaceId};
+use crate::{
+    NewWindow, SerializedWorkspaceLocation, WORKSPACE_DB, WorkspaceId, path_list::PathList,
+};
 
 pub fn init(cx: &mut App) {
     let manager = cx.new(|_| HistoryManager::new());
@@ -44,7 +46,13 @@ impl HistoryManager {
                 .unwrap_or_default()
                 .into_iter()
                 .rev()
-                .map(|(id, location)| HistoryManagerEntry::new(id, &location))
+                .filter_map(|(id, location, paths)| {
+                    if matches!(location, SerializedWorkspaceLocation::Local) {
+                        Some(HistoryManagerEntry::new(id, &paths))
+                    } else {
+                        None
+                    }
+                })
                 .collect::<Vec<_>>();
             this.update(cx, |this, cx| {
                 this.history = recent_folders;
@@ -118,9 +126,9 @@ impl HistoryManager {
 }
 
 impl HistoryManagerEntry {
-    pub fn new(id: WorkspaceId, location: &SerializedWorkspaceLocation) -> Self {
-        let path = location
-            .sorted_paths()
+    pub fn new(id: WorkspaceId, paths: &PathList) -> Self {
+        let path = paths
+            .paths()
             .iter()
             .map(|path| path.compact())
             .collect::<SmallVec<[PathBuf; 2]>>();

crates/workspace/src/path_list.rs 🔗

@@ -0,0 +1,121 @@
+use std::{
+    path::{Path, PathBuf},
+    sync::Arc,
+};
+
+use util::paths::SanitizedPath;
+
+/// A list of absolute paths, in a specific order.
+///
+/// The paths are stored in lexicographic order, so that they can be compared to
+/// other path lists without regard to the order of the paths.
+#[derive(Default, PartialEq, Eq, Debug, Clone)]
+pub struct PathList {
+    paths: Arc<[PathBuf]>,
+    order: Arc<[usize]>,
+}
+
+#[derive(Debug)]
+pub struct SerializedPathList {
+    pub paths: String,
+    pub order: String,
+}
+
+impl PathList {
+    pub fn new<P: AsRef<Path>>(paths: &[P]) -> Self {
+        let mut indexed_paths: Vec<(usize, PathBuf)> = paths
+            .iter()
+            .enumerate()
+            .map(|(ix, path)| (ix, SanitizedPath::from(path).into()))
+            .collect();
+        indexed_paths.sort_by(|(_, a), (_, b)| a.cmp(b));
+        let order = indexed_paths.iter().map(|e| e.0).collect::<Vec<_>>().into();
+        let paths = indexed_paths
+            .into_iter()
+            .map(|e| e.1)
+            .collect::<Vec<_>>()
+            .into();
+        Self { order, paths }
+    }
+
+    pub fn is_empty(&self) -> bool {
+        self.paths.is_empty()
+    }
+
+    pub fn paths(&self) -> &[PathBuf] {
+        self.paths.as_ref()
+    }
+
+    pub fn order(&self) -> &[usize] {
+        self.order.as_ref()
+    }
+
+    pub fn is_lexicographically_ordered(&self) -> bool {
+        self.order.iter().enumerate().all(|(i, &j)| i == j)
+    }
+
+    pub fn deserialize(serialized: &SerializedPathList) -> Self {
+        let mut paths: Vec<PathBuf> = if serialized.paths.is_empty() {
+            Vec::new()
+        } else {
+            serde_json::from_str::<Vec<PathBuf>>(&serialized.paths)
+                .unwrap_or(Vec::new())
+                .into_iter()
+                .map(|s| SanitizedPath::from(s).into())
+                .collect()
+        };
+
+        let mut order: Vec<usize> = serialized
+            .order
+            .split(',')
+            .filter_map(|s| s.parse().ok())
+            .collect();
+
+        if !paths.is_sorted() || order.len() != paths.len() {
+            order = (0..paths.len()).collect();
+            paths.sort();
+        }
+
+        Self {
+            paths: paths.into(),
+            order: order.into(),
+        }
+    }
+
+    pub fn serialize(&self) -> SerializedPathList {
+        use std::fmt::Write as _;
+
+        let paths = serde_json::to_string(&self.paths).unwrap_or_default();
+
+        let mut order = String::new();
+        for ix in self.order.iter() {
+            if !order.is_empty() {
+                order.push(',');
+            }
+            write!(&mut order, "{}", *ix).unwrap();
+        }
+        SerializedPathList { paths, order }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_path_list() {
+        let list1 = PathList::new(&["a/d", "a/c"]);
+        let list2 = PathList::new(&["a/c", "a/d"]);
+
+        assert_eq!(list1.paths(), list2.paths());
+        assert_ne!(list1, list2);
+        assert_eq!(list1.order(), &[1, 0]);
+        assert_eq!(list2.order(), &[0, 1]);
+
+        let list1_deserialized = PathList::deserialize(&list1.serialize());
+        assert_eq!(list1_deserialized, list1);
+
+        let list2_deserialized = PathList::deserialize(&list2.serialize());
+        assert_eq!(list2_deserialized, list2);
+    }
+}

crates/workspace/src/persistence.rs 🔗

@@ -9,10 +9,8 @@ use std::{
 };
 
 use anyhow::{Context as _, Result, bail};
-use client::DevServerProjectId;
 use db::{define_connection, query, sqlez::connection::Connection, sqlez_macros::sql};
 use gpui::{Axis, Bounds, Task, WindowBounds, WindowId, point, size};
-use itertools::Itertools;
 use project::debugger::breakpoint_store::{BreakpointState, SourceBreakpoint};
 
 use language::{LanguageName, Toolchain};
@@ -28,14 +26,17 @@ use ui::{App, px};
 use util::{ResultExt, maybe};
 use uuid::Uuid;
 
-use crate::WorkspaceId;
+use crate::{
+    WorkspaceId,
+    path_list::{PathList, SerializedPathList},
+};
 
 use model::{
-    GroupId, ItemId, LocalPaths, PaneId, SerializedItem, SerializedPane, SerializedPaneGroup,
-    SerializedSshProject, SerializedWorkspace,
+    GroupId, ItemId, PaneId, SerializedItem, SerializedPane, SerializedPaneGroup,
+    SerializedSshConnection, SerializedWorkspace,
 };
 
-use self::model::{DockStructure, LocalPathsOrder, SerializedWorkspaceLocation};
+use self::model::{DockStructure, SerializedWorkspaceLocation};
 
 #[derive(Copy, Clone, Debug, PartialEq)]
 pub(crate) struct SerializedAxis(pub(crate) gpui::Axis);
@@ -275,70 +276,9 @@ impl sqlez::bindable::Bind for SerializedPixels {
 }
 
 define_connection! {
-    // Current schema shape using pseudo-rust syntax:
-    //
-    // workspaces(
-    //   workspace_id: usize, // Primary key for workspaces
-    //   local_paths: Bincode<Vec<PathBuf>>,
-    //   local_paths_order: Bincode<Vec<usize>>,
-    //   dock_visible: bool, // Deprecated
-    //   dock_anchor: DockAnchor, // Deprecated
-    //   dock_pane: Option<usize>, // Deprecated
-    //   left_sidebar_open: boolean,
-    //   timestamp: String, // UTC YYYY-MM-DD HH:MM:SS
-    //   window_state: String, // WindowBounds Discriminant
-    //   window_x: Option<f32>, // WindowBounds::Fixed RectF x
-    //   window_y: Option<f32>, // WindowBounds::Fixed RectF y
-    //   window_width: Option<f32>, // WindowBounds::Fixed RectF width
-    //   window_height: Option<f32>, // WindowBounds::Fixed RectF height
-    //   display: Option<Uuid>, // Display id
-    //   fullscreen: Option<bool>, // Is the window fullscreen?
-    //   centered_layout: Option<bool>, // Is the Centered Layout mode activated?
-    //   session_id: Option<String>, // Session id
-    //   window_id: Option<u64>, // Window Id
-    // )
-    //
-    // pane_groups(
-    //   group_id: usize, // Primary key for pane_groups
-    //   workspace_id: usize, // References workspaces table
-    //   parent_group_id: Option<usize>, // None indicates that this is the root node
-    //   position: Option<usize>, // None indicates that this is the root node
-    //   axis: Option<Axis>, // 'Vertical', 'Horizontal'
-    //   flexes: Option<Vec<f32>>, // A JSON array of floats
-    // )
-    //
-    // panes(
-    //     pane_id: usize, // Primary key for panes
-    //     workspace_id: usize, // References workspaces table
-    //     active: bool,
-    // )
-    //
-    // center_panes(
-    //     pane_id: usize, // Primary key for center_panes
-    //     parent_group_id: Option<usize>, // References pane_groups. If none, this is the root
-    //     position: Option<usize>, // None indicates this is the root
-    // )
-    //
-    // CREATE TABLE items(
-    //     item_id: usize, // This is the item's view id, so this is not unique
-    //     workspace_id: usize, // References workspaces table
-    //     pane_id: usize, // References panes table
-    //     kind: String, // Indicates which view this connects to. This is the key in the item_deserializers global
-    //     position: usize, // Position of the item in the parent pane. This is equivalent to panes' position column
-    //     active: bool, // Indicates if this item is the active one in the pane
-    //     preview: bool // Indicates if this item is a preview item
-    // )
-    //
-    // CREATE TABLE breakpoints(
-    //      workspace_id: usize Foreign Key, // References workspace table
-    //      path: PathBuf, // The absolute path of the file that this breakpoint belongs to
-    //      breakpoint_location: Vec<u32>, // A list of the locations of breakpoints
-    //      kind: int, // The kind of breakpoint (standard, log)
-    //      log_message: String, // log message for log breakpoints, otherwise it's Null
-    // )
     pub static ref DB: WorkspaceDb<()> =
     &[
-        sql!(
+    sql!(
         CREATE TABLE workspaces(
             workspace_id INTEGER PRIMARY KEY,
             workspace_location BLOB UNIQUE,
@@ -555,7 +495,109 @@ define_connection! {
             SELECT * FROM toolchains;
         DROP TABLE toolchains;
         ALTER TABLE toolchains2 RENAME TO toolchains;
-    )
+    ),
+    sql!(
+        CREATE TABLE ssh_connections (
+            id INTEGER PRIMARY KEY,
+            host TEXT NOT NULL,
+            port INTEGER,
+            user TEXT
+        );
+
+        INSERT INTO ssh_connections (host, port, user)
+        SELECT DISTINCT host, port, user
+        FROM ssh_projects;
+
+        CREATE TABLE workspaces_2(
+            workspace_id INTEGER PRIMARY KEY,
+            paths TEXT,
+            paths_order TEXT,
+            ssh_connection_id INTEGER REFERENCES ssh_connections(id),
+            timestamp TEXT DEFAULT CURRENT_TIMESTAMP NOT NULL,
+            window_state TEXT,
+            window_x REAL,
+            window_y REAL,
+            window_width REAL,
+            window_height REAL,
+            display BLOB,
+            left_dock_visible INTEGER,
+            left_dock_active_panel TEXT,
+            right_dock_visible INTEGER,
+            right_dock_active_panel TEXT,
+            bottom_dock_visible INTEGER,
+            bottom_dock_active_panel TEXT,
+            left_dock_zoom INTEGER,
+            right_dock_zoom INTEGER,
+            bottom_dock_zoom INTEGER,
+            fullscreen INTEGER,
+            centered_layout INTEGER,
+            session_id TEXT,
+            window_id INTEGER
+        ) STRICT;
+
+        INSERT
+        INTO workspaces_2
+        SELECT
+            workspaces.workspace_id,
+            CASE
+                WHEN ssh_projects.id IS NOT NULL THEN ssh_projects.paths
+                ELSE
+                    CASE
+                        WHEN workspaces.local_paths_array IS NULL OR workspaces.local_paths_array = "" THEN
+                            NULL
+                        ELSE
+                            json('[' || '"' || replace(workspaces.local_paths_array, ',', '"' || "," || '"') || '"' || ']')
+                    END
+            END as paths,
+
+            CASE
+                WHEN ssh_projects.id IS NOT NULL THEN ""
+                ELSE workspaces.local_paths_order_array
+            END as paths_order,
+
+            CASE
+                WHEN ssh_projects.id IS NOT NULL THEN (
+                    SELECT ssh_connections.id
+                    FROM ssh_connections
+                    WHERE
+                        ssh_connections.host IS ssh_projects.host AND
+                        ssh_connections.port IS ssh_projects.port AND
+                        ssh_connections.user IS ssh_projects.user
+                )
+                ELSE NULL
+            END as ssh_connection_id,
+
+            workspaces.timestamp,
+            workspaces.window_state,
+            workspaces.window_x,
+            workspaces.window_y,
+            workspaces.window_width,
+            workspaces.window_height,
+            workspaces.display,
+            workspaces.left_dock_visible,
+            workspaces.left_dock_active_panel,
+            workspaces.right_dock_visible,
+            workspaces.right_dock_active_panel,
+            workspaces.bottom_dock_visible,
+            workspaces.bottom_dock_active_panel,
+            workspaces.left_dock_zoom,
+            workspaces.right_dock_zoom,
+            workspaces.bottom_dock_zoom,
+            workspaces.fullscreen,
+            workspaces.centered_layout,
+            workspaces.session_id,
+            workspaces.window_id
+        FROM
+            workspaces LEFT JOIN
+            ssh_projects ON
+            workspaces.ssh_project_id = ssh_projects.id;
+
+        DROP TABLE ssh_projects;
+        DROP TABLE workspaces;
+        ALTER TABLE workspaces_2 RENAME TO workspaces;
+
+        CREATE UNIQUE INDEX ix_workspaces_location ON workspaces(ssh_connection_id, paths);
+    ),
     ];
 }
 
@@ -566,17 +608,33 @@ impl WorkspaceDb {
     pub(crate) fn workspace_for_roots<P: AsRef<Path>>(
         &self,
         worktree_roots: &[P],
+    ) -> Option<SerializedWorkspace> {
+        self.workspace_for_roots_internal(worktree_roots, None)
+    }
+
+    pub(crate) fn ssh_workspace_for_roots<P: AsRef<Path>>(
+        &self,
+        worktree_roots: &[P],
+        ssh_project_id: SshProjectId,
+    ) -> Option<SerializedWorkspace> {
+        self.workspace_for_roots_internal(worktree_roots, Some(ssh_project_id))
+    }
+
+    pub(crate) fn workspace_for_roots_internal<P: AsRef<Path>>(
+        &self,
+        worktree_roots: &[P],
+        ssh_connection_id: Option<SshProjectId>,
     ) -> Option<SerializedWorkspace> {
         // paths are sorted before db interactions to ensure that the order of the paths
         // doesn't affect the workspace selection for existing workspaces
-        let local_paths = LocalPaths::new(worktree_roots);
+        let root_paths = PathList::new(worktree_roots);
 
         // Note that we re-assign the workspace_id here in case it's empty
         // and we've grabbed the most recent workspace
         let (
             workspace_id,
-            local_paths,
-            local_paths_order,
+            paths,
+            paths_order,
             window_bounds,
             display,
             centered_layout,
@@ -584,8 +642,8 @@ impl WorkspaceDb {
             window_id,
         ): (
             WorkspaceId,
-            Option<LocalPaths>,
-            Option<LocalPathsOrder>,
+            String,
+            String,
             Option<SerializedWindowBounds>,
             Option<Uuid>,
             Option<bool>,
@@ -595,8 +653,8 @@ impl WorkspaceDb {
             .select_row_bound(sql! {
                 SELECT
                     workspace_id,
-                    local_paths,
-                    local_paths_order,
+                    paths,
+                    paths_order,
                     window_state,
                     window_x,
                     window_y,
@@ -615,25 +673,31 @@ impl WorkspaceDb {
                     bottom_dock_zoom,
                     window_id
                 FROM workspaces
-                WHERE local_paths = ?
+                WHERE
+                    paths IS ? AND
+                    ssh_connection_id IS ?
+                LIMIT 1
+            })
+            .map(|mut prepared_statement| {
+                (prepared_statement)((
+                    root_paths.serialize().paths,
+                    ssh_connection_id.map(|id| id.0 as i32),
+                ))
+                .unwrap()
             })
-            .and_then(|mut prepared_statement| (prepared_statement)(&local_paths))
             .context("No workspaces found")
             .warn_on_err()
             .flatten()?;
 
-        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)
-            }
-        };
+        let paths = PathList::deserialize(&SerializedPathList {
+            paths,
+            order: paths_order,
+        });
 
         Some(SerializedWorkspace {
             id: workspace_id,
-            location,
+            location: SerializedWorkspaceLocation::Local,
+            paths,
             center_group: self
                 .get_center_pane_group(workspace_id)
                 .context("Getting center group")
@@ -648,63 +712,6 @@ 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),
-            breakpoints: self.breakpoints(workspace_id),
-            display,
-            docks,
-            session_id: None,
-            window_id,
-        })
-    }
-
     fn breakpoints(&self, workspace_id: WorkspaceId) -> BTreeMap<Arc<Path>, Vec<SourceBreakpoint>> {
         let breakpoints: Result<Vec<(PathBuf, Breakpoint)>> = self
             .select_bound(sql! {
@@ -754,6 +761,13 @@ impl WorkspaceDb {
     /// 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) {
+        let paths = workspace.paths.serialize();
+        let ssh_connection_id = match &workspace.location {
+            SerializedWorkspaceLocation::Local => None,
+            SerializedWorkspaceLocation::Ssh(serialized_ssh_connection) => {
+                Some(serialized_ssh_connection.id.0)
+            }
+        };
         log::debug!("Saving workspace at location: {:?}", workspace.location);
         self.write(move |conn| {
             conn.with_savepoint("update_worktrees", || {
@@ -763,7 +777,12 @@ impl WorkspaceDb {
                     DELETE FROM panes WHERE workspace_id = ?1;))?(workspace.id)
                     .context("Clearing old panes")?;
 
-                conn.exec_bound(sql!(DELETE FROM breakpoints WHERE workspace_id = ?1))?(workspace.id).context("Clearing old breakpoints")?;
+                conn.exec_bound(
+                    sql!(
+                        DELETE FROM breakpoints WHERE workspace_id = ?1;
+                        DELETE FROM toolchains WHERE workspace_id = ?1;
+                    )
+                )?(workspace.id).context("Clearing old breakpoints")?;
 
                 for (path, breakpoints) in workspace.breakpoints {
                     for bp in breakpoints {
@@ -790,115 +809,73 @@ impl WorkspaceDb {
                             }
                         }
                     }
-
                 }
 
+                conn.exec_bound(sql!(
+                    DELETE
+                    FROM workspaces
+                    WHERE
+                        workspace_id != ?1 AND
+                        paths IS ?2 AND
+                        ssh_connection_id IS ?3
+                ))?((
+                    workspace.id,
+                    paths.paths.clone(),
+                    ssh_connection_id,
+                ))
+                .context("clearing out old locations")?;
 
-                match workspace.location {
-                    SerializedWorkspaceLocation::Local(local_paths, local_paths_order) => {
-                        conn.exec_bound(sql!(
-                            DELETE FROM toolchains WHERE workspace_id = ?1;
-                            DELETE FROM workspaces WHERE local_paths = ? AND workspace_id != ?
-                        ))?((&local_paths, workspace.id))
-                        .context("clearing out old locations")?;
-
-                        // Upsert
-                        let query = sql!(
-                            INSERT INTO workspaces(
-                                workspace_id,
-                                local_paths,
-                                local_paths_order,
-                                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,
-                                session_id,
-                                window_id,
-                                timestamp,
-                                local_paths_array,
-                                local_paths_order_array
-                            )
-                            VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, CURRENT_TIMESTAMP, ?15, ?16)
-                            ON CONFLICT DO
-                            UPDATE SET
-                                local_paths = ?2,
-                                local_paths_order = ?3,
-                                left_dock_visible = ?4,
-                                left_dock_active_panel = ?5,
-                                left_dock_zoom = ?6,
-                                right_dock_visible = ?7,
-                                right_dock_active_panel = ?8,
-                                right_dock_zoom = ?9,
-                                bottom_dock_visible = ?10,
-                                bottom_dock_active_panel = ?11,
-                                bottom_dock_zoom = ?12,
-                                session_id = ?13,
-                                window_id = ?14,
-                                timestamp = CURRENT_TIMESTAMP,
-                                local_paths_array = ?15,
-                                local_paths_order_array = ?16
-                        );
-                        let mut prepared_query = conn.exec_bound(query)?;
-                        let args = (workspace.id, &local_paths, &local_paths_order, workspace.docks, workspace.session_id, workspace.window_id, local_paths.paths().iter().map(|path| path.to_string_lossy().to_string()).join(","), local_paths_order.order().iter().map(|order| order.to_string()).join(","));
-
-                        prepared_query(args).context("Updating workspace")?;
-                    }
-                    SerializedWorkspaceLocation::Ssh(ssh_project) => {
-                        conn.exec_bound(sql!(
-                            DELETE FROM toolchains WHERE workspace_id = ?1;
-                            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,
-                                session_id,
-                                window_id,
-                                timestamp
-                            )
-                            VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, 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,
-                                session_id = ?12,
-                                window_id = ?13,
-                                timestamp = CURRENT_TIMESTAMP
-                        ))?((
-                            workspace.id,
-                            ssh_project.id.0,
-                            workspace.docks,
-                            workspace.session_id,
-                            workspace.window_id
-                        ))
-                        .context("Updating workspace")?;
-                    }
-                }
+                // Upsert
+                let query = sql!(
+                    INSERT INTO workspaces(
+                        workspace_id,
+                        paths,
+                        paths_order,
+                        ssh_connection_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,
+                        session_id,
+                        window_id,
+                        timestamp
+                    )
+                    VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, CURRENT_TIMESTAMP)
+                    ON CONFLICT DO
+                    UPDATE SET
+                        paths = ?2,
+                        paths_order = ?3,
+                        ssh_connection_id = ?4,
+                        left_dock_visible = ?5,
+                        left_dock_active_panel = ?6,
+                        left_dock_zoom = ?7,
+                        right_dock_visible = ?8,
+                        right_dock_active_panel = ?9,
+                        right_dock_zoom = ?10,
+                        bottom_dock_visible = ?11,
+                        bottom_dock_active_panel = ?12,
+                        bottom_dock_zoom = ?13,
+                        session_id = ?14,
+                        window_id = ?15,
+                        timestamp = CURRENT_TIMESTAMP
+                );
+                let mut prepared_query = conn.exec_bound(query)?;
+                let args = (
+                    workspace.id,
+                    paths.paths.clone(),
+                    paths.order.clone(),
+                    ssh_connection_id,
+                    workspace.docks,
+                    workspace.session_id,
+                    workspace.window_id,
+                );
+
+                prepared_query(args).context("Updating workspace")?;
 
                 // Save center pane group
                 Self::save_pane_group(conn, workspace.id, &workspace.center_group, None)
@@ -911,89 +888,100 @@ impl WorkspaceDb {
         .await;
     }
 
-    pub(crate) async fn get_or_create_ssh_project(
+    pub(crate) async fn get_or_create_ssh_connection(
         &self,
         host: String,
         port: Option<u16>,
-        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, paths.clone(), user.clone())
+    ) -> Result<SshProjectId> {
+        if let Some(id) = self
+            .get_ssh_connection(host.clone(), port, user.clone())
             .await?
         {
-            Ok(project)
+            Ok(SshProjectId(id))
         } else {
             log::debug!("Inserting SSH project at host {host}");
-            self.insert_ssh_project(host, port, paths, user)
+            let id = self
+                .insert_ssh_connection(host, port, user)
                 .await?
-                .context("failed to insert ssh project")
+                .context("failed to insert ssh project")?;
+            Ok(SshProjectId(id))
         }
     }
 
     query! {
-        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 paths IS ? AND user IS ?
+        async fn get_ssh_connection(host: String, port: Option<u16>, user: Option<String>) -> Result<Option<u64>> {
+            SELECT id
+            FROM ssh_connections
+            WHERE host IS ? AND port IS ? AND user IS ?
             LIMIT 1
         }
     }
 
     query! {
-        async fn insert_ssh_project(host: String, port: Option<u16>, paths: String, user: Option<String>) -> Result<Option<SerializedSshProject>> {
-            INSERT INTO ssh_projects(
+        async fn insert_ssh_connection(host: String, port: Option<u16>, user: Option<String>) -> Result<Option<u64>> {
+            INSERT INTO ssh_connections (
                 host,
                 port,
-                paths,
                 user
-            ) VALUES (?1, ?2, ?3, ?4)
-            RETURNING id, host, port, paths, user
+            ) VALUES (?1, ?2, ?3)
+            RETURNING id
         }
     }
 
-    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
         }
     }
 
+    fn recent_workspaces(&self) -> Result<Vec<(WorkspaceId, PathList, Option<u64>)>> {
+        Ok(self
+            .recent_workspaces_query()?
+            .into_iter()
+            .map(|(id, paths, order, ssh_connection_id)| {
+                (
+                    id,
+                    PathList::deserialize(&SerializedPathList { paths, order }),
+                    ssh_connection_id,
+                )
+            })
+            .collect())
+    }
+
     query! {
-        fn recent_workspaces() -> Result<Vec<(WorkspaceId, LocalPaths, LocalPathsOrder, Option<u64>)>> {
-            SELECT workspace_id, local_paths, local_paths_order, ssh_project_id
+        fn recent_workspaces_query() -> Result<Vec<(WorkspaceId, String, String, Option<u64>)>> {
+            SELECT workspace_id, paths, paths_order, ssh_connection_id
             FROM workspaces
-            WHERE local_paths IS NOT NULL
-                OR ssh_project_id IS NOT NULL
+            WHERE
+                paths IS NOT NULL OR
+                ssh_connection_id IS NOT NULL
             ORDER BY timestamp DESC
         }
     }
 
+    fn session_workspaces(
+        &self,
+        session_id: String,
+    ) -> Result<Vec<(PathList, Option<u64>, Option<SshProjectId>)>> {
+        Ok(self
+            .session_workspaces_query(session_id)?
+            .into_iter()
+            .map(|(paths, order, window_id, ssh_connection_id)| {
+                (
+                    PathList::deserialize(&SerializedPathList { paths, order }),
+                    window_id,
+                    ssh_connection_id.map(SshProjectId),
+                )
+            })
+            .collect())
+    }
+
     query! {
-        fn session_workspaces(session_id: String) -> Result<Vec<(LocalPaths, LocalPathsOrder, Option<u64>, Option<u64>)>> {
-            SELECT local_paths, local_paths_order, window_id, ssh_project_id
+        fn session_workspaces_query(session_id: String) -> Result<Vec<(String, String, Option<u64>, Option<u64>)>> {
+            SELECT paths, paths_order, window_id, ssh_connection_id
             FROM workspaces
-            WHERE session_id = ?1 AND dev_server_project_id IS NULL
+            WHERE session_id = ?1
             ORDER BY timestamp DESC
         }
     }
@@ -1013,17 +1001,40 @@ impl WorkspaceDb {
         }
     }
 
+    fn ssh_connections(&self) -> Result<Vec<SerializedSshConnection>> {
+        Ok(self
+            .ssh_connections_query()?
+            .into_iter()
+            .map(|(id, host, port, user)| SerializedSshConnection {
+                id: SshProjectId(id),
+                host,
+                port,
+                user,
+            })
+            .collect())
+    }
+
     query! {
-        fn ssh_projects() -> Result<Vec<SerializedSshProject>> {
-            SELECT id, host, port, paths, user
-            FROM ssh_projects
+        pub fn ssh_connections_query() -> Result<Vec<(u64, String, Option<u16>, Option<String>)>> {
+            SELECT id, host, port, user
+            FROM ssh_connections
         }
     }
 
+    pub fn ssh_connection(&self, id: SshProjectId) -> Result<SerializedSshConnection> {
+        let row = self.ssh_connection_query(id.0)?;
+        Ok(SerializedSshConnection {
+            id: SshProjectId(row.0),
+            host: row.1,
+            port: row.2,
+            user: row.3,
+        })
+    }
+
     query! {
-        fn ssh_project(id: u64) -> Result<SerializedSshProject> {
-            SELECT id, host, port, paths, user
-            FROM ssh_projects
+        fn ssh_connection_query(id: u64) -> Result<(u64, String, Option<u16>, Option<String>)> {
+            SELECT id, host, port, user
+            FROM ssh_connections
             WHERE id = ?
         }
     }
@@ -1037,7 +1048,7 @@ impl WorkspaceDb {
                 display,
                 window_state, window_x, window_y, window_width, window_height
                 FROM workspaces
-                WHERE local_paths
+                WHERE paths
                 IS NOT NULL
                 ORDER BY timestamp DESC
                 LIMIT 1
@@ -1054,46 +1065,35 @@ impl WorkspaceDb {
         }
     }
 
-    pub async fn delete_workspace_by_dev_server_project_id(
-        &self,
-        id: DevServerProjectId,
-    ) -> Result<()> {
-        self.write(move |conn| {
-            conn.exec_bound(sql!(
-                DELETE FROM dev_server_projects WHERE id = ?
-            ))?(id.0)?;
-            conn.exec_bound(sql!(
-                DELETE FROM toolchains WHERE workspace_id = ?1;
-                DELETE FROM workspaces
-                WHERE dev_server_project_id IS ?
-            ))?(id.0)
-        })
-        .await
-    }
-
     // Returns the recent locations which are still valid on disk and deletes ones which no longer
     // exist.
     pub async fn recent_workspaces_on_disk(
         &self,
-    ) -> Result<Vec<(WorkspaceId, SerializedWorkspaceLocation)>> {
+    ) -> Result<Vec<(WorkspaceId, SerializedWorkspaceLocation, PathList)>> {
         let mut result = Vec::new();
         let mut delete_tasks = Vec::new();
-        let ssh_projects = self.ssh_projects()?;
-
-        for (id, location, order, ssh_project_id) in self.recent_workspaces()? {
-            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())));
+        let ssh_connections = self.ssh_connections()?;
+
+        for (id, paths, ssh_connection_id) in self.recent_workspaces()? {
+            if let Some(ssh_connection_id) = ssh_connection_id.map(SshProjectId) {
+                if let Some(ssh_connection) =
+                    ssh_connections.iter().find(|rp| rp.id == ssh_connection_id)
+                {
+                    result.push((
+                        id,
+                        SerializedWorkspaceLocation::Ssh(ssh_connection.clone()),
+                        paths,
+                    ));
                 } 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())
+            if paths.paths().iter().all(|path| path.exists())
+                && paths.paths().iter().any(|path| path.is_dir())
             {
-                result.push((id, SerializedWorkspaceLocation::Local(location, order)));
+                result.push((id, SerializedWorkspaceLocation::Local, paths));
             } else {
                 delete_tasks.push(self.delete_workspace_by_id(id));
             }
@@ -1103,13 +1103,13 @@ impl WorkspaceDb {
         Ok(result)
     }
 
-    pub async fn last_workspace(&self) -> Result<Option<SerializedWorkspaceLocation>> {
+    pub async fn last_workspace(&self) -> Result<Option<(SerializedWorkspaceLocation, PathList)>> {
         Ok(self
             .recent_workspaces_on_disk()
             .await?
             .into_iter()
             .next()
-            .map(|(_, location)| location))
+            .map(|(_, location, paths)| (location, paths)))
     }
 
     // Returns the locations of the workspaces that were still opened when the last
@@ -1120,25 +1120,31 @@ impl WorkspaceDb {
         &self,
         last_session_id: &str,
         last_session_window_stack: Option<Vec<WindowId>>,
-    ) -> Result<Vec<SerializedWorkspaceLocation>> {
+    ) -> Result<Vec<(SerializedWorkspaceLocation, PathList)>> {
         let mut workspaces = Vec::new();
 
-        for (location, order, window_id, ssh_project_id) in
+        for (paths, window_id, ssh_connection_id) in
             self.session_workspaces(last_session_id.to_owned())?
         {
-            if let Some(ssh_project_id) = ssh_project_id {
-                let location = SerializedWorkspaceLocation::Ssh(self.ssh_project(ssh_project_id)?);
-                workspaces.push((location, window_id.map(WindowId::from)));
-            } else if location.paths().iter().all(|path| path.exists())
-                && location.paths().iter().any(|path| path.is_dir())
+            if let Some(ssh_connection_id) = ssh_connection_id {
+                workspaces.push((
+                    SerializedWorkspaceLocation::Ssh(self.ssh_connection(ssh_connection_id)?),
+                    paths,
+                    window_id.map(WindowId::from),
+                ));
+            } else if paths.paths().iter().all(|path| path.exists())
+                && paths.paths().iter().any(|path| path.is_dir())
             {
-                let location = SerializedWorkspaceLocation::Local(location, order);
-                workspaces.push((location, window_id.map(WindowId::from)));
+                workspaces.push((
+                    SerializedWorkspaceLocation::Local,
+                    paths,
+                    window_id.map(WindowId::from),
+                ));
             }
         }
 
         if let Some(stack) = last_session_window_stack {
-            workspaces.sort_by_key(|(_, window_id)| {
+            workspaces.sort_by_key(|(_, _, window_id)| {
                 window_id
                     .and_then(|id| stack.iter().position(|&order_id| order_id == id))
                     .unwrap_or(usize::MAX)
@@ -1147,7 +1153,7 @@ impl WorkspaceDb {
 
         Ok(workspaces
             .into_iter()
-            .map(|(paths, _)| paths)
+            .map(|(location, paths, _)| (location, paths))
             .collect::<Vec<_>>())
     }
 
@@ -1499,13 +1505,13 @@ pub fn delete_unloaded_items(
 
 #[cfg(test)]
 mod tests {
-    use std::thread;
-    use std::time::Duration;
-
     use super::*;
-    use crate::persistence::model::SerializedWorkspace;
-    use crate::persistence::model::{SerializedItem, SerializedPane, SerializedPaneGroup};
+    use crate::persistence::model::{
+        SerializedItem, SerializedPane, SerializedPaneGroup, SerializedWorkspace,
+    };
     use gpui;
+    use pretty_assertions::assert_eq;
+    use std::{thread, time::Duration};
 
     #[gpui::test]
     async fn test_breakpoints() {
@@ -1558,7 +1564,8 @@ mod tests {
 
         let workspace = SerializedWorkspace {
             id,
-            location: SerializedWorkspaceLocation::from_local_paths(["/tmp"]),
+            paths: PathList::new(&["/tmp"]),
+            location: SerializedWorkspaceLocation::Local,
             center_group: Default::default(),
             window_bounds: Default::default(),
             display: Default::default(),
@@ -1711,7 +1718,8 @@ mod tests {
 
         let workspace = SerializedWorkspace {
             id,
-            location: SerializedWorkspaceLocation::from_local_paths(["/tmp"]),
+            paths: PathList::new(&["/tmp"]),
+            location: SerializedWorkspaceLocation::Local,
             center_group: Default::default(),
             window_bounds: Default::default(),
             display: Default::default(),
@@ -1757,7 +1765,8 @@ mod tests {
 
         let workspace_without_breakpoint = SerializedWorkspace {
             id,
-            location: SerializedWorkspaceLocation::from_local_paths(["/tmp"]),
+            paths: PathList::new(&["/tmp"]),
+            location: SerializedWorkspaceLocation::Local,
             center_group: Default::default(),
             window_bounds: Default::default(),
             display: Default::default(),
@@ -1851,7 +1860,8 @@ mod tests {
 
         let mut workspace_1 = SerializedWorkspace {
             id: WorkspaceId(1),
-            location: SerializedWorkspaceLocation::from_local_paths(["/tmp", "/tmp2"]),
+            paths: PathList::new(&["/tmp", "/tmp2"]),
+            location: SerializedWorkspaceLocation::Local,
             center_group: Default::default(),
             window_bounds: Default::default(),
             display: Default::default(),
@@ -1864,7 +1874,8 @@ mod tests {
 
         let workspace_2 = SerializedWorkspace {
             id: WorkspaceId(2),
-            location: SerializedWorkspaceLocation::from_local_paths(["/tmp"]),
+            paths: PathList::new(&["/tmp"]),
+            location: SerializedWorkspaceLocation::Local,
             center_group: Default::default(),
             window_bounds: Default::default(),
             display: Default::default(),
@@ -1893,7 +1904,7 @@ mod tests {
         })
         .await;
 
-        workspace_1.location = SerializedWorkspaceLocation::from_local_paths(["/tmp", "/tmp3"]);
+        workspace_1.paths = PathList::new(&["/tmp", "/tmp3"]);
         db.save_workspace(workspace_1.clone()).await;
         db.save_workspace(workspace_1).await;
         db.save_workspace(workspace_2).await;
@@ -1969,10 +1980,8 @@ mod tests {
 
         let workspace = SerializedWorkspace {
             id: WorkspaceId(5),
-            location: SerializedWorkspaceLocation::Local(
-                LocalPaths::new(["/tmp", "/tmp2"]),
-                LocalPathsOrder::new([1, 0]),
-            ),
+            paths: PathList::new(&["/tmp", "/tmp2"]),
+            location: SerializedWorkspaceLocation::Local,
             center_group,
             window_bounds: Default::default(),
             breakpoints: Default::default(),

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

@@ -1,15 +1,16 @@
 use super::{SerializedAxis, SerializedWindowBounds};
 use crate::{
     Member, Pane, PaneAxis, SerializableItemRegistry, Workspace, WorkspaceId, item::ItemHandle,
+    path_list::PathList,
 };
-use anyhow::{Context as _, Result};
+use anyhow::Result;
 use async_recursion::async_recursion;
 use db::sqlez::{
     bindable::{Bind, Column, StaticColumnCount},
     statement::Statement,
 };
 use gpui::{AsyncWindowContext, Entity, WeakEntity};
-use itertools::Itertools as _;
+
 use project::{Project, debugger::breakpoint_store::SourceBreakpoint};
 use remote::ssh_session::SshProjectId;
 use serde::{Deserialize, Serialize};
@@ -18,239 +19,27 @@ use std::{
     path::{Path, PathBuf},
     sync::Arc,
 };
-use util::{ResultExt, paths::SanitizedPath};
+use util::ResultExt;
 use uuid::Uuid;
 
 #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
-pub struct SerializedSshProject {
+pub struct SerializedSshConnection {
     pub id: SshProjectId,
     pub host: String,
     pub port: Option<u16>,
-    pub paths: Vec<String>,
     pub user: Option<String>,
 }
 
-impl SerializedSshProject {
-    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()
-    }
-}
-
-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 raw_paths = serde_json::to_string(&self.paths)?;
-        let next_index = statement.bind(&raw_paths, 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 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((
-            Self {
-                id: SshProjectId(id as u64),
-                host,
-                port,
-                paths,
-                user,
-            },
-            start_index + 5,
-        ))
-    }
-}
-
-#[derive(Debug, PartialEq, Clone)]
-pub struct LocalPaths(Arc<Vec<PathBuf>>);
-
-impl LocalPaths {
-    pub fn new<P: AsRef<Path>>(paths: impl IntoIterator<Item = P>) -> Self {
-        let mut paths: Vec<PathBuf> = paths
-            .into_iter()
-            .map(|p| SanitizedPath::from(p).into())
-            .collect();
-        // Ensure all future `zed workspace1 workspace2` and `zed workspace2 workspace1` calls are using the same workspace.
-        // The actual workspace order is stored in the `LocalPathsOrder` struct.
-        paths.sort();
-        Self(Arc::new(paths))
-    }
-
-    pub fn paths(&self) -> &Arc<Vec<PathBuf>> {
-        &self.0
-    }
-}
-
-impl StaticColumnCount for LocalPaths {}
-impl Bind for &LocalPaths {
-    fn bind(&self, statement: &Statement, start_index: i32) -> Result<i32> {
-        statement.bind(&bincode::serialize(&self.0)?, start_index)
-    }
-}
-
-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() {
-            Default::default()
-        } else {
-            bincode::deserialize(path_blob).context("Bincode deserialization of paths failed")?
-        };
-
-        Ok((Self(paths), start_index + 1))
-    }
-}
-
-#[derive(Debug, PartialEq, Clone)]
-pub struct LocalPathsOrder(Vec<usize>);
-
-impl LocalPathsOrder {
-    pub fn new(order: impl IntoIterator<Item = usize>) -> Self {
-        Self(order.into_iter().collect())
-    }
-
-    pub fn order(&self) -> &[usize] {
-        self.0.as_slice()
-    }
-
-    pub fn default_for_paths(paths: &LocalPaths) -> Self {
-        Self::new(0..paths.0.len())
-    }
-}
-
-impl StaticColumnCount for LocalPathsOrder {}
-impl Bind for &LocalPathsOrder {
-    fn bind(&self, statement: &Statement, start_index: i32) -> Result<i32> {
-        statement.bind(&bincode::serialize(&self.0)?, start_index)
-    }
-}
-
-impl Column for LocalPathsOrder {
-    fn column(statement: &mut Statement, start_index: i32) -> Result<(Self, i32)> {
-        let order_blob = statement.column_blob(start_index)?;
-        let order = if order_blob.is_empty() {
-            Vec::new()
-        } else {
-            bincode::deserialize(order_blob).context("deserializing workspace root order")?
-        };
-
-        Ok((Self(order), start_index + 1))
-    }
-}
-
 #[derive(Debug, PartialEq, Clone)]
 pub enum SerializedWorkspaceLocation {
-    Local(LocalPaths, LocalPathsOrder),
-    Ssh(SerializedSshProject),
+    Local,
+    Ssh(SerializedSshConnection),
 }
 
 impl SerializedWorkspaceLocation {
-    /// Create a new `SerializedWorkspaceLocation` from a list of local paths.
-    ///
-    /// The paths will be sorted and the order will be stored in the `LocalPathsOrder` struct.
-    ///
-    /// # Examples
-    ///
-    /// ```
-    /// use std::path::Path;
-    /// use zed_workspace::SerializedWorkspaceLocation;
-    ///
-    /// let location = SerializedWorkspaceLocation::from_local_paths(vec![
-    ///     Path::new("path/to/workspace1"),
-    ///     Path::new("path/to/workspace2"),
-    /// ]);
-    /// assert_eq!(location, SerializedWorkspaceLocation::Local(
-    ///    LocalPaths::new(vec![
-    ///         Path::new("path/to/workspace1"),
-    ///         Path::new("path/to/workspace2"),
-    ///    ]),
-    ///   LocalPathsOrder::new(vec![0, 1]),
-    /// ));
-    /// ```
-    ///
-    /// ```
-    /// use std::path::Path;
-    /// use zed_workspace::SerializedWorkspaceLocation;
-    ///
-    /// let location = SerializedWorkspaceLocation::from_local_paths(vec![
-    ///     Path::new("path/to/workspace2"),
-    ///     Path::new("path/to/workspace1"),
-    /// ]);
-    ///
-    /// assert_eq!(location, SerializedWorkspaceLocation::Local(
-    ///    LocalPaths::new(vec![
-    ///         Path::new("path/to/workspace1"),
-    ///         Path::new("path/to/workspace2"),
-    ///   ]),
-    ///  LocalPathsOrder::new(vec![1, 0]),
-    /// ));
-    /// ```
-    pub fn from_local_paths<P: AsRef<Path>>(paths: impl IntoIterator<Item = P>) -> Self {
-        let mut indexed_paths: Vec<_> = paths
-            .into_iter()
-            .map(|p| p.as_ref().to_path_buf())
-            .enumerate()
-            .collect();
-
-        indexed_paths.sort_by(|(_, a), (_, b)| a.cmp(b));
-
-        let sorted_paths: Vec<_> = indexed_paths.iter().map(|(_, path)| path.clone()).collect();
-        let order: Vec<_> = indexed_paths.iter().map(|(index, _)| *index).collect();
-
-        Self::Local(LocalPaths::new(sorted_paths), LocalPathsOrder::new(order))
-    }
-
     /// Get sorted paths
     pub fn sorted_paths(&self) -> Arc<Vec<PathBuf>> {
-        match self {
-            SerializedWorkspaceLocation::Local(paths, order) => {
-                if order.order().is_empty() {
-                    paths.paths().clone()
-                } else {
-                    Arc::new(
-                        order
-                            .order()
-                            .iter()
-                            .zip(paths.paths().iter())
-                            .sorted_by_key(|(i, _)| **i)
-                            .map(|(_, p)| p.clone())
-                            .collect(),
-                    )
-                }
-            }
-            SerializedWorkspaceLocation::Ssh(ssh_project) => Arc::new(ssh_project.ssh_urls()),
-        }
+        unimplemented!()
     }
 }
 
@@ -258,6 +47,7 @@ impl SerializedWorkspaceLocation {
 pub(crate) struct SerializedWorkspace {
     pub(crate) id: WorkspaceId,
     pub(crate) location: SerializedWorkspaceLocation,
+    pub(crate) paths: PathList,
     pub(crate) center_group: SerializedPaneGroup,
     pub(crate) window_bounds: Option<SerializedWindowBounds>,
     pub(crate) centered_layout: bool,
@@ -581,80 +371,3 @@ impl Column for SerializedItem {
         ))
     }
 }
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-
-    #[test]
-    fn test_serialize_local_paths() {
-        let paths = vec!["b", "a", "c"];
-        let serialized = SerializedWorkspaceLocation::from_local_paths(paths);
-
-        assert_eq!(
-            serialized,
-            SerializedWorkspaceLocation::Local(
-                LocalPaths::new(vec!["a", "b", "c"]),
-                LocalPathsOrder::new(vec![1, 0, 2])
-            )
-        );
-    }
-
-    #[test]
-    fn test_sorted_paths() {
-        let paths = vec!["b", "a", "c"];
-        let serialized = SerializedWorkspaceLocation::from_local_paths(paths);
-        assert_eq!(
-            serialized.sorted_paths(),
-            Arc::new(vec![
-                PathBuf::from("b"),
-                PathBuf::from("a"),
-                PathBuf::from("c"),
-            ])
-        );
-
-        let paths = Arc::new(vec![
-            PathBuf::from("a"),
-            PathBuf::from("b"),
-            PathBuf::from("c"),
-        ]);
-        let order = vec![2, 0, 1];
-        let serialized =
-            SerializedWorkspaceLocation::Local(LocalPaths(paths), LocalPathsOrder(order));
-        assert_eq!(
-            serialized.sorted_paths(),
-            Arc::new(vec![
-                PathBuf::from("b"),
-                PathBuf::from("c"),
-                PathBuf::from("a"),
-            ])
-        );
-
-        let paths = Arc::new(vec![
-            PathBuf::from("a"),
-            PathBuf::from("b"),
-            PathBuf::from("c"),
-        ]);
-        let order = vec![];
-        let serialized =
-            SerializedWorkspaceLocation::Local(LocalPaths(paths.clone()), LocalPathsOrder(order));
-        assert_eq!(serialized.sorted_paths(), paths);
-
-        let urls = ["/a", "/b", "/c"];
-        let serialized = SerializedWorkspaceLocation::Ssh(SerializedSshProject {
-            id: SshProjectId(0),
-            host: "host".to_string(),
-            port: Some(22),
-            paths: urls.iter().map(|s| s.to_string()).collect(),
-            user: Some("user".to_string()),
-        });
-        assert_eq!(
-            serialized.sorted_paths(),
-            Arc::new(
-                urls.iter()
-                    .map(|p| PathBuf::from(format!("user@host:22{}", p)))
-                    .collect()
-            )
-        );
-    }
-}

crates/workspace/src/workspace.rs 🔗

@@ -6,6 +6,7 @@ mod modal_layer;
 pub mod notifications;
 pub mod pane;
 pub mod pane_group;
+mod path_list;
 mod persistence;
 pub mod searchable;
 pub mod shared_screen;
@@ -18,6 +19,7 @@ mod workspace_settings;
 
 pub use crate::notifications::NotificationFrame;
 pub use dock::Panel;
+pub use path_list::PathList;
 pub use toast_layer::{ToastAction, ToastLayer, ToastView};
 
 use anyhow::{Context as _, Result, anyhow};
@@ -62,20 +64,20 @@ use notifications::{
 };
 pub use pane::*;
 pub use pane_group::*;
-use persistence::{
-    DB, SerializedWindowBounds,
-    model::{SerializedSshProject, SerializedWorkspace},
-};
+use persistence::{DB, SerializedWindowBounds, model::SerializedWorkspace};
 pub use persistence::{
     DB as WORKSPACE_DB, WorkspaceDb, delete_unloaded_items,
-    model::{ItemId, LocalPaths, SerializedWorkspaceLocation},
+    model::{ItemId, SerializedSshConnection, SerializedWorkspaceLocation},
 };
 use postage::stream::Stream;
 use project::{
     DirectoryLister, Project, ProjectEntryId, ProjectPath, ResolvedPath, Worktree, WorktreeId,
     debugger::{breakpoint_store::BreakpointStoreEvent, session::ThreadStatus},
 };
-use remote::{SshClientDelegate, SshConnectionOptions, ssh_session::ConnectionIdentifier};
+use remote::{
+    SshClientDelegate, SshConnectionOptions,
+    ssh_session::{ConnectionIdentifier, SshProjectId},
+};
 use schemars::JsonSchema;
 use serde::Deserialize;
 use session::AppSession;
@@ -1042,7 +1044,7 @@ pub enum OpenVisible {
 
 enum WorkspaceLocation {
     // Valid local paths or SSH project to serialize
-    Location(SerializedWorkspaceLocation),
+    Location(SerializedWorkspaceLocation, PathList),
     // No valid location found hence clear session id
     DetachFromSession,
     // No valid location found to serialize
@@ -1126,7 +1128,7 @@ pub struct Workspace {
     terminal_provider: Option<Box<dyn TerminalProvider>>,
     debugger_provider: Option<Arc<dyn DebuggerProvider>>,
     serializable_items_tx: UnboundedSender<Box<dyn SerializableItemHandle>>,
-    serialized_ssh_project: Option<SerializedSshProject>,
+    serialized_ssh_connection_id: Option<SshProjectId>,
     _items_serializer: Task<Result<()>>,
     session_id: Option<String>,
     scheduled_tasks: Vec<Task<()>>,
@@ -1175,8 +1177,6 @@ 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);
@@ -1461,7 +1461,7 @@ impl Workspace {
             serializable_items_tx,
             _items_serializer,
             session_id: Some(session_id),
-            serialized_ssh_project: None,
+            serialized_ssh_connection_id: None,
             scheduled_tasks: Vec::new(),
         }
     }
@@ -1501,20 +1501,9 @@ impl Workspace {
             let serialized_workspace =
                 persistence::DB.workspace_for_roots(paths_to_open.as_slice());
 
-            let workspace_location = serialized_workspace
-                .as_ref()
-                .map(|ws| &ws.location)
-                .and_then(|loc| match loc {
-                    SerializedWorkspaceLocation::Local(_, order) => {
-                        Some((loc.sorted_paths(), order.order()))
-                    }
-                    _ => None,
-                });
-
-            if let Some((paths, order)) = workspace_location {
-                paths_to_open = paths.iter().cloned().collect();
-
-                if order.iter().enumerate().any(|(i, &j)| i != j) {
+            if let Some(paths) = serialized_workspace.as_ref().map(|ws| &ws.paths) {
+                paths_to_open = paths.paths().to_vec();
+                if !paths.is_lexicographically_ordered() {
                     project_handle
                         .update(cx, |project, cx| {
                             project.set_worktrees_reordered(true, cx);
@@ -2034,14 +2023,6 @@ impl Workspace {
         self.debugger_provider.clone()
     }
 
-    pub fn serialized_ssh_project(&self) -> Option<SerializedSshProject> {
-        self.serialized_ssh_project.clone()
-    }
-
-    pub fn set_serialized_ssh_project(&mut self, serialized_ssh_project: SerializedSshProject) {
-        self.serialized_ssh_project = Some(serialized_ssh_project);
-    }
-
     pub fn prompt_for_open_path(
         &mut self,
         path_prompt_options: PathPromptOptions,
@@ -5088,59 +5069,12 @@ impl Workspace {
         self.session_id.clone()
     }
 
-    fn local_paths(&self, cx: &App) -> Option<Vec<Arc<Path>>> {
-        let project = self.project().read(cx);
-
-        if project.is_local() {
-            Some(
-                project
-                    .visible_worktrees(cx)
-                    .map(|worktree| worktree.read(cx).abs_path())
-                    .collect::<Vec<_>>(),
-            )
-        } else {
-            None
-        }
-    }
-
-    fn update_ssh_paths(&mut self, cx: &App) {
+    pub fn root_paths(&self, cx: &App) -> Vec<Arc<Path>> {
         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();
-                }));
-        }
+        project
+            .visible_worktrees(cx)
+            .map(|worktree| worktree.read(cx).abs_path())
+            .collect::<Vec<_>>()
     }
 
     fn remove_panes(&mut self, member: Member, window: &mut Window, cx: &mut Context<Workspace>) {
@@ -5313,7 +5247,7 @@ impl Workspace {
         }
 
         match self.serialize_workspace_location(cx) {
-            WorkspaceLocation::Location(location) => {
+            WorkspaceLocation::Location(location, paths) => {
                 let breakpoints = self.project.update(cx, |project, cx| {
                     project
                         .breakpoint_store()
@@ -5327,6 +5261,7 @@ impl Workspace {
                 let serialized_workspace = SerializedWorkspace {
                     id: database_id,
                     location,
+                    paths,
                     center_group,
                     window_bounds,
                     display: Default::default(),
@@ -5352,13 +5287,21 @@ impl Workspace {
     }
 
     fn serialize_workspace_location(&self, cx: &App) -> WorkspaceLocation {
-        if let Some(ssh_project) = &self.serialized_ssh_project {
-            WorkspaceLocation::Location(SerializedWorkspaceLocation::Ssh(ssh_project.clone()))
-        } else if let Some(local_paths) = self.local_paths(cx) {
-            if !local_paths.is_empty() {
-                WorkspaceLocation::Location(SerializedWorkspaceLocation::from_local_paths(
-                    local_paths,
-                ))
+        let paths = PathList::new(&self.root_paths(cx));
+        let connection = self.project.read(cx).ssh_connection_options(cx);
+        if let Some((id, connection)) = self.serialized_ssh_connection_id.zip(connection) {
+            WorkspaceLocation::Location(
+                SerializedWorkspaceLocation::Ssh(SerializedSshConnection {
+                    id,
+                    host: connection.host,
+                    port: connection.port,
+                    user: connection.username,
+                }),
+                paths,
+            )
+        } else if self.project.read(cx).is_local() {
+            if !paths.is_empty() {
+                WorkspaceLocation::Location(SerializedWorkspaceLocation::Local, paths)
             } else {
                 WorkspaceLocation::DetachFromSession
             }
@@ -5371,13 +5314,13 @@ impl Workspace {
         let Some(id) = self.database_id() else {
             return;
         };
-        let location = match self.serialize_workspace_location(cx) {
-            WorkspaceLocation::Location(location) => location,
-            _ => return,
-        };
+        if !self.project.read(cx).is_local() {
+            return;
+        }
         if let Some(manager) = HistoryManager::global(cx) {
+            let paths = PathList::new(&self.root_paths(cx));
             manager.update(cx, |this, cx| {
-                this.update_history(id, HistoryManagerEntry::new(id, &location), cx);
+                this.update_history(id, HistoryManagerEntry::new(id, &paths), cx);
             });
         }
     }
@@ -6843,14 +6786,14 @@ impl WorkspaceHandle for Entity<Workspace> {
     }
 }
 
-pub async fn last_opened_workspace_location() -> Option<SerializedWorkspaceLocation> {
+pub async fn last_opened_workspace_location() -> Option<(SerializedWorkspaceLocation, PathList)> {
     DB.last_workspace().await.log_err().flatten()
 }
 
 pub fn last_session_workspace_locations(
     last_session_id: &str,
     last_session_window_stack: Option<Vec<WindowId>>,
-) -> Option<Vec<SerializedWorkspaceLocation>> {
+) -> Option<Vec<(SerializedWorkspaceLocation, PathList)>> {
     DB.last_session_workspace_locations(last_session_id, last_session_window_stack)
         .log_err()
 }
@@ -7353,7 +7296,7 @@ pub fn open_ssh_project_with_new_connection(
     cx: &mut App,
 ) -> Task<Result<()>> {
     cx.spawn(async move |cx| {
-        let (serialized_ssh_project, workspace_id, serialized_workspace) =
+        let (workspace_id, serialized_workspace) =
             serialize_ssh_project(connection_options.clone(), paths.clone(), cx).await?;
 
         let session = match cx
@@ -7387,7 +7330,6 @@ pub fn open_ssh_project_with_new_connection(
         open_ssh_project_inner(
             project,
             paths,
-            serialized_ssh_project,
             workspace_id,
             serialized_workspace,
             app_state,
@@ -7407,13 +7349,12 @@ pub fn open_ssh_project_with_existing_connection(
     cx: &mut AsyncApp,
 ) -> Task<Result<()>> {
     cx.spawn(async move |cx| {
-        let (serialized_ssh_project, workspace_id, serialized_workspace) =
+        let (workspace_id, serialized_workspace) =
             serialize_ssh_project(connection_options.clone(), paths.clone(), cx).await?;
 
         open_ssh_project_inner(
             project,
             paths,
-            serialized_ssh_project,
             workspace_id,
             serialized_workspace,
             app_state,
@@ -7427,7 +7368,6 @@ pub fn open_ssh_project_with_existing_connection(
 async fn open_ssh_project_inner(
     project: Entity<Project>,
     paths: Vec<PathBuf>,
-    serialized_ssh_project: SerializedSshProject,
     workspace_id: WorkspaceId,
     serialized_workspace: Option<SerializedWorkspace>,
     app_state: Arc<AppState>,
@@ -7480,7 +7420,6 @@ async fn open_ssh_project_inner(
 
             let mut workspace =
                 Workspace::new(Some(workspace_id), project, app_state.clone(), window, cx);
-            workspace.set_serialized_ssh_project(serialized_ssh_project);
             workspace.update_history(cx);
 
             if let Some(ref serialized) = serialized_workspace {
@@ -7517,28 +7456,18 @@ fn serialize_ssh_project(
     connection_options: SshConnectionOptions,
     paths: Vec<PathBuf>,
     cx: &AsyncApp,
-) -> Task<
-    Result<(
-        SerializedSshProject,
-        WorkspaceId,
-        Option<SerializedWorkspace>,
-    )>,
-> {
+) -> Task<Result<(WorkspaceId, Option<SerializedWorkspace>)>> {
     cx.background_spawn(async move {
-        let serialized_ssh_project = persistence::DB
-            .get_or_create_ssh_project(
+        let ssh_connection_id = persistence::DB
+            .get_or_create_ssh_connection(
                 connection_options.host.clone(),
                 connection_options.port,
-                paths
-                    .iter()
-                    .map(|path| path.to_string_lossy().to_string())
-                    .collect::<Vec<_>>(),
                 connection_options.username.clone(),
             )
             .await?;
 
         let serialized_workspace =
-            persistence::DB.workspace_for_ssh_project(&serialized_ssh_project);
+            persistence::DB.ssh_workspace_for_roots(&paths, ssh_connection_id);
 
         let workspace_id = if let Some(workspace_id) =
             serialized_workspace.as_ref().map(|workspace| workspace.id)
@@ -7548,7 +7477,7 @@ fn serialize_ssh_project(
             persistence::DB.next_id().await?
         };
 
-        Ok((serialized_ssh_project, workspace_id, serialized_workspace))
+        Ok((workspace_id, serialized_workspace))
     })
 }
 
@@ -8095,18 +8024,15 @@ pub fn ssh_workspace_position_from_db(
     paths_to_open: &[PathBuf],
     cx: &App,
 ) -> Task<Result<WorkspacePosition>> {
-    let paths = paths_to_open
-        .iter()
-        .map(|path| path.to_string_lossy().to_string())
-        .collect::<Vec<_>>();
+    let paths = paths_to_open.to_vec();
 
     cx.background_spawn(async move {
-        let serialized_ssh_project = persistence::DB
-            .get_or_create_ssh_project(host, port, paths, user)
+        let ssh_connection_id = persistence::DB
+            .get_or_create_ssh_connection(host, port, user)
             .await
             .context("fetching serialized ssh project")?;
         let serialized_workspace =
-            persistence::DB.workspace_for_ssh_project(&serialized_ssh_project);
+            persistence::DB.ssh_workspace_for_roots(&paths, ssh_connection_id);
 
         let (window_bounds, display) = if let Some(bounds) = window_bounds_env_override() {
             (Some(WindowBounds::Windowed(bounds)), None)

crates/zed/src/main.rs 🔗

@@ -47,8 +47,8 @@ use theme::{
 use util::{ResultExt, TryFutureExt, maybe};
 use uuid::Uuid;
 use workspace::{
-    AppState, SerializedWorkspaceLocation, Toast, Workspace, WorkspaceSettings, WorkspaceStore,
-    notifications::NotificationId,
+    AppState, PathList, SerializedWorkspaceLocation, Toast, Workspace, WorkspaceSettings,
+    WorkspaceStore, notifications::NotificationId,
 };
 use zed::{
     OpenListener, OpenRequest, RawOpenRequest, app_menus, build_window_options,
@@ -949,15 +949,14 @@ async fn restore_or_create_workspace(app_state: Arc<AppState>, cx: &mut AsyncApp
     if let Some(locations) = restorable_workspace_locations(cx, &app_state).await {
         let mut tasks = Vec::new();
 
-        for location in locations {
+        for (location, paths) in locations {
             match location {
-                SerializedWorkspaceLocation::Local(location, _) => {
+                SerializedWorkspaceLocation::Local => {
                     let app_state = app_state.clone();
-                    let paths = location.paths().to_vec();
                     let task = cx.spawn(async move |cx| {
                         let open_task = cx.update(|cx| {
                             workspace::open_paths(
-                                &paths,
+                                &paths.paths(),
                                 app_state,
                                 workspace::OpenOptions::default(),
                                 cx,
@@ -979,7 +978,7 @@ async fn restore_or_create_workspace(app_state: Arc<AppState>, cx: &mut AsyncApp
                         match connection_options {
                             Ok(connection_options) => recent_projects::open_ssh_project(
                                 connection_options,
-                                ssh.paths.into_iter().map(PathBuf::from).collect(),
+                                paths.paths().into_iter().map(PathBuf::from).collect(),
                                 app_state,
                                 workspace::OpenOptions::default(),
                                 cx,
@@ -1070,7 +1069,7 @@ async fn restore_or_create_workspace(app_state: Arc<AppState>, cx: &mut AsyncApp
 pub(crate) async fn restorable_workspace_locations(
     cx: &mut AsyncApp,
     app_state: &Arc<AppState>,
-) -> Option<Vec<SerializedWorkspaceLocation>> {
+) -> Option<Vec<(SerializedWorkspaceLocation, PathList)>> {
     let mut restore_behavior = cx
         .update(|cx| WorkspaceSettings::get(None, cx).restore_on_startup)
         .ok()?;

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

@@ -26,6 +26,7 @@ use std::thread;
 use std::time::Duration;
 use util::ResultExt;
 use util::paths::PathWithPosition;
+use workspace::PathList;
 use workspace::item::ItemHandle;
 use workspace::{AppState, OpenOptions, SerializedWorkspaceLocation, Workspace};
 
@@ -361,12 +362,14 @@ async fn open_workspaces(
         if open_new_workspace == Some(true) {
             Vec::new()
         } else {
-            let locations = restorable_workspace_locations(cx, &app_state).await;
-            locations.unwrap_or_default()
+            restorable_workspace_locations(cx, &app_state)
+                .await
+                .unwrap_or_default()
         }
     } else {
-        vec![SerializedWorkspaceLocation::from_local_paths(
-            paths.into_iter().map(PathBuf::from),
+        vec![(
+            SerializedWorkspaceLocation::Local,
+            PathList::new(&paths.into_iter().map(PathBuf::from).collect::<Vec<_>>()),
         )]
     };
 
@@ -394,9 +397,9 @@ async fn open_workspaces(
         // If there are paths to open, open a workspace for each grouping of paths
         let mut errored = false;
 
-        for location in grouped_locations {
+        for (location, workspace_paths) in grouped_locations {
             match location {
-                SerializedWorkspaceLocation::Local(workspace_paths, _) => {
+                SerializedWorkspaceLocation::Local => {
                     let workspace_paths = workspace_paths
                         .paths()
                         .iter()
@@ -429,7 +432,7 @@ async fn open_workspaces(
                         cx.spawn(async move |cx| {
                             open_ssh_project(
                                 connection_options,
-                                ssh.paths.into_iter().map(PathBuf::from).collect(),
+                                workspace_paths.paths().to_vec(),
                                 app_state,
                                 OpenOptions::default(),
                                 cx,