remote projects per user (#10594)

Conrad Irwin , Bennet Bo Fenner , Bennet , Nate Butler , and Nate Butler created

Release Notes:

- Made remote projects per-user instead of per-channel. If you'd like to
be part of the remote development alpha, please email hi@zed.dev.

---------

Co-authored-by: Bennet Bo Fenner <53836821+bennetbo@users.noreply.github.com>
Co-authored-by: Bennet <bennetbo@gmx.de>
Co-authored-by: Nate Butler <1714999+iamnbutler@users.noreply.github.com>
Co-authored-by: Nate Butler <iamnbutler@gmail.com>

Change summary

Cargo.lock                                                                  |  23 
Cargo.toml                                                                  |   2 
assets/icons/server.svg                                                     |  19 
crates/call/src/room.rs                                                     |  24 
crates/channel/src/channel.rs                                               |   4 
crates/channel/src/channel_store.rs                                         | 206 
crates/client/src/user.rs                                                   |   4 
crates/collab/Cargo.toml                                                    |   1 
crates/collab/migrations.sqlite/20221109000000_test_schema.sql              |   9 
crates/collab/migrations/20240412165156_dev_servers_per_user.sql            |   7 
crates/collab/migrations/20240417192746_unique_remote_projects_by_paths.sql |   3 
crates/collab/src/db.rs                                                     |   6 
crates/collab/src/db/queries/channels.rs                                    |   5 
crates/collab/src/db/queries/dev_servers.rs                                 | 100 
crates/collab/src/db/queries/projects.rs                                    | 118 
crates/collab/src/db/queries/remote_projects.rs                             |  98 
crates/collab/src/db/queries/rooms.rs                                       |  80 
crates/collab/src/db/tables/dev_server.rs                                   |  16 
crates/collab/src/db/tables/remote_project.rs                               |  18 
crates/collab/src/db/tests/db_tests.rs                                      |   6 
crates/collab/src/rpc.rs                                                    | 296 
crates/collab/src/rpc/connection_pool.rs                                    |  10 
crates/collab/src/tests/channel_tests.rs                                    |   2 
crates/collab/src/tests/dev_server_tests.rs                                 | 323 
crates/collab/src/tests/integration_tests.rs                                |   4 
crates/collab/src/tests/test_server.rs                                      |   1 
crates/collab_ui/Cargo.toml                                                 |   1 
crates/collab_ui/src/channel_view.rs                                        |   4 
crates/collab_ui/src/collab_panel.rs                                        | 148 
crates/collab_ui/src/collab_panel/dev_server_modal.rs                       | 622 
crates/collab_ui/src/collab_titlebar_item.rs                                | 108 
crates/editor/src/items.rs                                                  |   1 
crates/headless/src/headless.rs                                             |  73 
crates/picker/src/highlighted_match_with_paths.rs                           |  11 
crates/project/src/project.rs                                               |  38 
crates/recent_projects/Cargo.toml                                           |   7 
crates/recent_projects/src/recent_projects.rs                               | 303 
crates/recent_projects/src/remote_projects.rs                               | 749 
crates/remote_projects/Cargo.toml                                           |  23 
crates/remote_projects/src/remote_projects.rs                               | 186 
crates/rpc/proto/zed.proto                                                  |  44 
crates/rpc/src/proto.rs                                                     |   7 
crates/sqlez/src/connection.rs                                              | 105 
crates/sqlez/src/statement.rs                                               |   1 
crates/tasks_ui/src/modal.rs                                                |   1 
crates/ui/src/components/icon.rs                                            |  64 
crates/ui/src/components/modal.rs                                           |  41 
crates/ui_text_field/src/ui_text_field.rs                                   |  35 
crates/workspace/Cargo.toml                                                 |   1 
crates/workspace/src/item.rs                                                |   8 
crates/workspace/src/persistence.rs                                         | 281 
crates/workspace/src/persistence/model.rs                                   |  99 
crates/workspace/src/workspace.rs                                           |  73 
crates/zed/Cargo.toml                                                       |   1 
crates/zed/src/main.rs                                                      |   1 
script/zed-local                                                            |   5 
56 files changed, 2,804 insertions(+), 1,622 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -2254,6 +2254,7 @@ dependencies = [
  "prost",
  "rand 0.8.5",
  "release_channel",
+ "remote_projects",
  "reqwest",
  "rpc",
  "rustc-demangle",
@@ -2299,7 +2300,6 @@ dependencies = [
  "editor",
  "emojis",
  "extensions_ui",
- "feature_flags",
  "futures 0.3.28",
  "fuzzy",
  "gpui",
@@ -7728,7 +7728,9 @@ dependencies = [
 name = "recent_projects"
 version = "0.1.0"
 dependencies = [
+ "anyhow",
  "editor",
+ "feature_flags",
  "fuzzy",
  "gpui",
  "language",
@@ -7736,10 +7738,15 @@ dependencies = [
  "ordered-float 2.10.0",
  "picker",
  "project",
+ "remote_projects",
+ "rpc",
  "serde",
  "serde_json",
+ "settings",
  "smol",
+ "theme",
  "ui",
+ "ui_text_field",
  "util",
  "workspace",
 ]
@@ -7866,6 +7873,18 @@ dependencies = [
  "once_cell",
 ]
 
+[[package]]
+name = "remote_projects"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "client",
+ "gpui",
+ "rpc",
+ "serde",
+ "serde_json",
+]
+
 [[package]]
 name = "rend"
 version = "0.4.0"
@@ -12303,6 +12322,7 @@ dependencies = [
  "parking_lot",
  "postage",
  "project",
+ "remote_projects",
  "schemars",
  "serde",
  "serde_json",
@@ -12601,6 +12621,7 @@ dependencies = [
  "quick_action_bar",
  "recent_projects",
  "release_channel",
+ "remote_projects",
  "rope",
  "search",
  "serde",

Cargo.toml 🔗

@@ -67,6 +67,7 @@ members = [
     "crates/refineable",
     "crates/refineable/derive_refineable",
     "crates/release_channel",
+    "crates/remote_projects",
     "crates/rich_text",
     "crates/rope",
     "crates/rpc",
@@ -200,6 +201,7 @@ project_symbols = { path = "crates/project_symbols" }
 quick_action_bar = { path = "crates/quick_action_bar" }
 recent_projects = { path = "crates/recent_projects" }
 release_channel = { path = "crates/release_channel" }
+remote_projects = { path = "crates/remote_projects" }
 rich_text = { path = "crates/rich_text" }
 rope = { path = "crates/rope" }
 rpc = { path = "crates/rpc" }

assets/icons/server.svg 🔗

@@ -1,5 +1,16 @@
-<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M7.99993 6.85713C11.1558 6.85713 13.7142 5.83379 13.7142 4.57142C13.7142 3.30905 11.1558 2.28571 7.99993 2.28571C4.84402 2.28571 2.28564 3.30905 2.28564 4.57142C2.28564 5.83379 4.84402 6.85713 7.99993 6.85713Z" fill="black" stroke="black" stroke-width="1.5"/>
-<path d="M13.7142 4.57141V11.4286C13.7142 12.691 11.1558 13.7143 7.99993 13.7143C4.84402 13.7143 2.28564 12.691 2.28564 11.4286V4.57141" stroke="black" stroke-width="1.5"/>
-<path d="M13.7142 8C13.7142 9.26237 11.1558 10.2857 7.99993 10.2857C4.84402 10.2857 2.28564 9.26237 2.28564 8" stroke="black" stroke-width="1.5"/>
+<svg
+  xmlns="http://www.w3.org/2000/svg"
+  width="24"
+  height="24"
+  viewBox="0 0 24 24"
+  fill="none"
+  stroke="currentColor"
+  stroke-width="2"
+  stroke-linecap="round"
+  stroke-linejoin="round"
+>
+  <rect width="20" height="8" x="2" y="2" rx="2" ry="2" />
+  <rect width="20" height="8" x="2" y="14" rx="2" ry="2" />
+  <line x1="6" x2="6.01" y1="6" y2="6" />
+  <line x1="6" x2="6.01" y1="18" y2="18" />
 </svg>

crates/call/src/room.rs 🔗

@@ -1203,14 +1203,24 @@ impl Room {
         project: Model<Project>,
         cx: &mut ModelContext<Self>,
     ) -> Task<Result<u64>> {
-        if let Some(project_id) = project.read(cx).remote_id() {
-            return Task::ready(Ok(project_id));
-        }
+        let request = if let Some(remote_project_id) = project.read(cx).remote_project_id() {
+            self.client.request(proto::ShareProject {
+                room_id: self.id(),
+                worktrees: vec![],
+                remote_project_id: Some(remote_project_id.0),
+            })
+        } else {
+            if let Some(project_id) = project.read(cx).remote_id() {
+                return Task::ready(Ok(project_id));
+            }
+
+            self.client.request(proto::ShareProject {
+                room_id: self.id(),
+                worktrees: project.read(cx).worktree_metadata_protos(cx),
+                remote_project_id: None,
+            })
+        };
 
-        let request = self.client.request(proto::ShareProject {
-            room_id: self.id(),
-            worktrees: project.read(cx).worktree_metadata_protos(cx),
-        });
         cx.spawn(|this, mut cx| async move {
             let response = request.await?;
 

crates/channel/src/channel.rs 🔗

@@ -11,9 +11,7 @@ pub use channel_chat::{
     mentions_to_proto, ChannelChat, ChannelChatEvent, ChannelMessage, ChannelMessageId,
     MessageParams,
 };
-pub use channel_store::{
-    Channel, ChannelEvent, ChannelMembership, ChannelStore, DevServer, RemoteProject,
-};
+pub use channel_store::{Channel, ChannelEvent, ChannelMembership, ChannelStore};
 
 #[cfg(test)]
 mod channel_store_tests;

crates/channel/src/channel_store.rs 🔗

@@ -3,10 +3,7 @@ mod channel_index;
 use crate::{channel_buffer::ChannelBuffer, channel_chat::ChannelChat, ChannelMessage};
 use anyhow::{anyhow, Result};
 use channel_index::ChannelIndex;
-use client::{
-    ChannelId, Client, ClientSettings, DevServerId, ProjectId, RemoteProjectId, Subscription, User,
-    UserId, UserStore,
-};
+use client::{ChannelId, Client, ClientSettings, ProjectId, Subscription, User, UserId, UserStore};
 use collections::{hash_map, HashMap, HashSet};
 use futures::{channel::mpsc, future::Shared, Future, FutureExt, StreamExt};
 use gpui::{
@@ -15,7 +12,7 @@ use gpui::{
 };
 use language::Capability;
 use rpc::{
-    proto::{self, ChannelRole, ChannelVisibility, DevServerStatus},
+    proto::{self, ChannelRole, ChannelVisibility},
     TypedEnvelope,
 };
 use settings::Settings;
@@ -53,57 +50,12 @@ impl From<proto::HostedProject> for HostedProject {
         }
     }
 }
-
-#[derive(Debug, Clone)]
-pub struct RemoteProject {
-    pub id: RemoteProjectId,
-    pub project_id: Option<ProjectId>,
-    pub channel_id: ChannelId,
-    pub name: SharedString,
-    pub path: SharedString,
-    pub dev_server_id: DevServerId,
-}
-
-impl From<proto::RemoteProject> for RemoteProject {
-    fn from(project: proto::RemoteProject) -> Self {
-        Self {
-            id: RemoteProjectId(project.id),
-            project_id: project.project_id.map(|id| ProjectId(id)),
-            channel_id: ChannelId(project.channel_id),
-            name: project.name.into(),
-            path: project.path.into(),
-            dev_server_id: DevServerId(project.dev_server_id),
-        }
-    }
-}
-
-#[derive(Debug, Clone)]
-pub struct DevServer {
-    pub id: DevServerId,
-    pub channel_id: ChannelId,
-    pub name: SharedString,
-    pub status: DevServerStatus,
-}
-
-impl From<proto::DevServer> for DevServer {
-    fn from(dev_server: proto::DevServer) -> Self {
-        Self {
-            id: DevServerId(dev_server.dev_server_id),
-            channel_id: ChannelId(dev_server.channel_id),
-            status: dev_server.status(),
-            name: dev_server.name.into(),
-        }
-    }
-}
-
 pub struct ChannelStore {
     pub channel_index: ChannelIndex,
     channel_invitations: Vec<Arc<Channel>>,
     channel_participants: HashMap<ChannelId, Vec<Arc<User>>>,
     channel_states: HashMap<ChannelId, ChannelState>,
     hosted_projects: HashMap<ProjectId, HostedProject>,
-    remote_projects: HashMap<RemoteProjectId, RemoteProject>,
-    dev_servers: HashMap<DevServerId, DevServer>,
 
     outgoing_invites: HashSet<(ChannelId, UserId)>,
     update_channels_tx: mpsc::UnboundedSender<proto::UpdateChannels>,
@@ -133,8 +85,6 @@ pub struct ChannelState {
     observed_chat_message: Option<u64>,
     role: Option<ChannelRole>,
     projects: HashSet<ProjectId>,
-    dev_servers: HashSet<DevServerId>,
-    remote_projects: HashSet<RemoteProjectId>,
 }
 
 impl Channel {
@@ -265,8 +215,6 @@ impl ChannelStore {
             channel_index: ChannelIndex::default(),
             channel_participants: Default::default(),
             hosted_projects: Default::default(),
-            remote_projects: Default::default(),
-            dev_servers: Default::default(),
             outgoing_invites: Default::default(),
             opened_buffers: Default::default(),
             opened_chats: Default::default(),
@@ -366,40 +314,6 @@ impl ChannelStore {
         projects
     }
 
-    pub fn dev_servers_for_id(&self, channel_id: ChannelId) -> Vec<DevServer> {
-        let mut dev_servers: Vec<DevServer> = self
-            .channel_states
-            .get(&channel_id)
-            .map(|state| state.dev_servers.clone())
-            .unwrap_or_default()
-            .into_iter()
-            .flat_map(|id| self.dev_servers.get(&id).cloned())
-            .collect();
-        dev_servers.sort_by_key(|s| (s.name.clone(), s.id));
-        dev_servers
-    }
-
-    pub fn find_dev_server_by_id(&self, id: DevServerId) -> Option<&DevServer> {
-        self.dev_servers.get(&id)
-    }
-
-    pub fn find_remote_project_by_id(&self, id: RemoteProjectId) -> Option<&RemoteProject> {
-        self.remote_projects.get(&id)
-    }
-
-    pub fn remote_projects_for_id(&self, channel_id: ChannelId) -> Vec<RemoteProject> {
-        let mut remote_projects: Vec<RemoteProject> = self
-            .channel_states
-            .get(&channel_id)
-            .map(|state| state.remote_projects.clone())
-            .unwrap_or_default()
-            .into_iter()
-            .flat_map(|id| self.remote_projects.get(&id).cloned())
-            .collect();
-        remote_projects.sort_by_key(|p| (p.name.clone(), p.id));
-        remote_projects
-    }
-
     pub fn has_open_channel_buffer(&self, channel_id: ChannelId, _cx: &AppContext) -> bool {
         if let Some(buffer) = self.opened_buffers.get(&channel_id) {
             if let OpenedModelHandle::Open(buffer) = buffer {
@@ -901,46 +815,6 @@ impl ChannelStore {
             Ok(())
         })
     }
-
-    pub fn create_remote_project(
-        &mut self,
-        channel_id: ChannelId,
-        dev_server_id: DevServerId,
-        name: String,
-        path: String,
-        cx: &mut ModelContext<Self>,
-    ) -> Task<Result<proto::CreateRemoteProjectResponse>> {
-        let client = self.client.clone();
-        cx.background_executor().spawn(async move {
-            client
-                .request(proto::CreateRemoteProject {
-                    channel_id: channel_id.0,
-                    dev_server_id: dev_server_id.0,
-                    name,
-                    path,
-                })
-                .await
-        })
-    }
-
-    pub fn create_dev_server(
-        &mut self,
-        channel_id: ChannelId,
-        name: String,
-        cx: &mut ModelContext<Self>,
-    ) -> Task<Result<proto::CreateDevServerResponse>> {
-        let client = self.client.clone();
-        cx.background_executor().spawn(async move {
-            let result = client
-                .request(proto::CreateDevServer {
-                    channel_id: channel_id.0,
-                    name,
-                })
-                .await?;
-            Ok(result)
-        })
-    }
-
     pub fn get_channel_member_details(
         &self,
         channel_id: ChannelId,
@@ -1221,11 +1095,7 @@ impl ChannelStore {
             || !payload.latest_channel_message_ids.is_empty()
             || !payload.latest_channel_buffer_versions.is_empty()
             || !payload.hosted_projects.is_empty()
-            || !payload.deleted_hosted_projects.is_empty()
-            || !payload.dev_servers.is_empty()
-            || !payload.deleted_dev_servers.is_empty()
-            || !payload.remote_projects.is_empty()
-            || !payload.deleted_remote_projects.is_empty();
+            || !payload.deleted_hosted_projects.is_empty();
 
         if channels_changed {
             if !payload.delete_channels.is_empty() {
@@ -1313,60 +1183,6 @@ impl ChannelStore {
                         .remove_hosted_project(old_project.project_id);
                 }
             }
-
-            for remote_project in payload.remote_projects {
-                let remote_project: RemoteProject = remote_project.into();
-                if let Some(old_remote_project) = self
-                    .remote_projects
-                    .insert(remote_project.id, remote_project.clone())
-                {
-                    self.channel_states
-                        .entry(old_remote_project.channel_id)
-                        .or_default()
-                        .remove_remote_project(old_remote_project.id);
-                }
-                self.channel_states
-                    .entry(remote_project.channel_id)
-                    .or_default()
-                    .add_remote_project(remote_project.id);
-            }
-
-            for remote_project_id in payload.deleted_remote_projects {
-                let remote_project_id = RemoteProjectId(remote_project_id);
-
-                if let Some(old_project) = self.remote_projects.remove(&remote_project_id) {
-                    self.channel_states
-                        .entry(old_project.channel_id)
-                        .or_default()
-                        .remove_remote_project(old_project.id);
-                }
-            }
-
-            for dev_server in payload.dev_servers {
-                let dev_server: DevServer = dev_server.into();
-                if let Some(old_server) = self.dev_servers.insert(dev_server.id, dev_server.clone())
-                {
-                    self.channel_states
-                        .entry(old_server.channel_id)
-                        .or_default()
-                        .remove_dev_server(old_server.id);
-                }
-                self.channel_states
-                    .entry(dev_server.channel_id)
-                    .or_default()
-                    .add_dev_server(dev_server.id);
-            }
-
-            for dev_server_id in payload.deleted_dev_servers {
-                let dev_server_id = DevServerId(dev_server_id);
-
-                if let Some(old_server) = self.dev_servers.remove(&dev_server_id) {
-                    self.channel_states
-                        .entry(old_server.channel_id)
-                        .or_default()
-                        .remove_dev_server(old_server.id);
-                }
-            }
         }
 
         cx.notify();
@@ -1481,20 +1297,4 @@ impl ChannelState {
     fn remove_hosted_project(&mut self, project_id: ProjectId) {
         self.projects.remove(&project_id);
     }
-
-    fn add_remote_project(&mut self, remote_project_id: RemoteProjectId) {
-        self.remote_projects.insert(remote_project_id);
-    }
-
-    fn remove_remote_project(&mut self, remote_project_id: RemoteProjectId) {
-        self.remote_projects.remove(&remote_project_id);
-    }
-
-    fn add_dev_server(&mut self, dev_server_id: DevServerId) {
-        self.dev_servers.insert(dev_server_id);
-    }
-
-    fn remove_dev_server(&mut self, dev_server_id: DevServerId) {
-        self.dev_servers.remove(&dev_server_id);
-    }
 }

crates/client/src/user.rs 🔗

@@ -30,7 +30,9 @@ pub struct ProjectId(pub u64);
 #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
 pub struct DevServerId(pub u64);
 
-#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
+#[derive(
+    Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy, serde::Serialize, serde::Deserialize,
+)]
 pub struct RemoteProjectId(pub u64);
 
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]

crates/collab/Cargo.toml 🔗

@@ -93,6 +93,7 @@ notifications = { workspace = true, features = ["test-support"] }
 pretty_assertions.workspace = true
 project = { workspace = true, features = ["test-support"] }
 release_channel.workspace = true
+remote_projects.workspace = true
 rpc = { workspace = true, features = ["test-support"] }
 sea-orm = { version = "0.12.x", features = ["sqlx-sqlite"] }
 serde_json.workspace = true

crates/collab/migrations.sqlite/20221109000000_test_schema.sql 🔗

@@ -398,26 +398,21 @@ CREATE TABLE hosted_projects (
     channel_id INTEGER NOT NULL REFERENCES channels(id),
     name TEXT NOT NULL,
     visibility TEXT NOT NULL,
-    deleted_at TIMESTAMP NULL,
-    dev_server_id INTEGER REFERENCES dev_servers(id),
-    dev_server_path TEXT
+    deleted_at TIMESTAMP NULL
 );
 CREATE INDEX idx_hosted_projects_on_channel_id ON hosted_projects (channel_id);
 CREATE UNIQUE INDEX uix_hosted_projects_on_channel_id_and_name ON hosted_projects (channel_id, name) WHERE (deleted_at IS NULL);
 
 CREATE TABLE dev_servers (
     id INTEGER PRIMARY KEY AUTOINCREMENT,
-    channel_id INTEGER NOT NULL REFERENCES channels(id),
+    user_id INTEGER NOT NULL REFERENCES users(id),
     name TEXT NOT NULL,
     hashed_token TEXT NOT NULL
 );
-CREATE INDEX idx_dev_servers_on_channel_id ON dev_servers (channel_id);
 
 CREATE TABLE remote_projects (
     id INTEGER PRIMARY KEY AUTOINCREMENT,
-    channel_id INTEGER NOT NULL REFERENCES channels(id),
     dev_server_id INTEGER NOT NULL REFERENCES dev_servers(id),
-    name TEXT NOT NULL,
     path TEXT NOT NULL
 );
 

crates/collab/src/db.rs 🔗

@@ -655,8 +655,6 @@ pub struct ChannelsForUser {
     pub channel_memberships: Vec<channel_member::Model>,
     pub channel_participants: HashMap<ChannelId, Vec<UserId>>,
     pub hosted_projects: Vec<proto::HostedProject>,
-    pub dev_servers: Vec<dev_server::Model>,
-    pub remote_projects: Vec<proto::RemoteProject>,
 
     pub observed_buffer_versions: Vec<proto::ChannelBufferVersion>,
     pub observed_channel_messages: Vec<proto::ChannelMessageId>,
@@ -764,6 +762,7 @@ pub struct Project {
     pub collaborators: Vec<ProjectCollaborator>,
     pub worktrees: BTreeMap<u64, Worktree>,
     pub language_servers: Vec<proto::LanguageServer>,
+    pub remote_project_id: Option<RemoteProjectId>,
 }
 
 pub struct ProjectCollaborator {
@@ -786,8 +785,7 @@ impl ProjectCollaborator {
 #[derive(Debug)]
 pub struct LeftProject {
     pub id: ProjectId,
-    pub host_user_id: Option<UserId>,
-    pub host_connection_id: Option<ConnectionId>,
+    pub should_unshare: bool,
     pub connection_ids: Vec<ConnectionId>,
 }
 

crates/collab/src/db/queries/channels.rs 🔗

@@ -640,15 +640,10 @@ impl Database {
             .get_hosted_projects(&channel_ids, &roles_by_channel_id, tx)
             .await?;
 
-        let dev_servers = self.get_dev_servers(&channel_ids, tx).await?;
-        let remote_projects = self.get_remote_projects(&channel_ids, tx).await?;
-
         Ok(ChannelsForUser {
             channel_memberships,
             channels,
             hosted_projects,
-            dev_servers,
-            remote_projects,
             channel_participants,
             latest_buffer_versions,
             latest_channel_messages,

crates/collab/src/db/queries/dev_servers.rs 🔗

@@ -1,6 +1,9 @@
-use sea_orm::{ActiveValue, ColumnTrait, DatabaseTransaction, EntityTrait, QueryFilter};
+use rpc::proto;
+use sea_orm::{
+    ActiveValue, ColumnTrait, DatabaseTransaction, EntityTrait, IntoActiveModel, QueryFilter,
+};
 
-use super::{channel, dev_server, ChannelId, Database, DevServerId, UserId};
+use super::{dev_server, remote_project, Database, DevServerId, UserId};
 
 impl Database {
     pub async fn get_dev_server(
@@ -16,40 +19,105 @@ impl Database {
         .await
     }
 
-    pub async fn get_dev_servers(
+    pub async fn get_dev_servers(&self, user_id: UserId) -> crate::Result<Vec<dev_server::Model>> {
+        self.transaction(|tx| async move {
+            Ok(dev_server::Entity::find()
+                .filter(dev_server::Column::UserId.eq(user_id))
+                .all(&*tx)
+                .await?)
+        })
+        .await
+    }
+
+    pub async fn remote_projects_update(
+        &self,
+        user_id: UserId,
+    ) -> crate::Result<proto::RemoteProjectsUpdate> {
+        self.transaction(
+            |tx| async move { self.remote_projects_update_internal(user_id, &tx).await },
+        )
+        .await
+    }
+
+    pub async fn remote_projects_update_internal(
         &self,
-        channel_ids: &Vec<ChannelId>,
+        user_id: UserId,
         tx: &DatabaseTransaction,
-    ) -> crate::Result<Vec<dev_server::Model>> {
-        let servers = dev_server::Entity::find()
-            .filter(dev_server::Column::ChannelId.is_in(channel_ids.iter().map(|id| id.0)))
+    ) -> crate::Result<proto::RemoteProjectsUpdate> {
+        let dev_servers = dev_server::Entity::find()
+            .filter(dev_server::Column::UserId.eq(user_id))
+            .all(tx)
+            .await?;
+
+        let remote_projects = remote_project::Entity::find()
+            .filter(
+                remote_project::Column::DevServerId
+                    .is_in(dev_servers.iter().map(|d| d.id).collect::<Vec<_>>()),
+            )
+            .find_also_related(super::project::Entity)
             .all(tx)
             .await?;
-        Ok(servers)
+
+        Ok(proto::RemoteProjectsUpdate {
+            dev_servers: dev_servers
+                .into_iter()
+                .map(|d| d.to_proto(proto::DevServerStatus::Offline))
+                .collect(),
+            remote_projects: remote_projects
+                .into_iter()
+                .map(|(remote_project, project)| remote_project.to_proto(project))
+                .collect(),
+        })
     }
 
     pub async fn create_dev_server(
         &self,
-        channel_id: ChannelId,
         name: &str,
         hashed_access_token: &str,
         user_id: UserId,
-    ) -> crate::Result<(channel::Model, dev_server::Model)> {
+    ) -> crate::Result<(dev_server::Model, proto::RemoteProjectsUpdate)> {
         self.transaction(|tx| async move {
-            let channel = self.get_channel_internal(channel_id, &tx).await?;
-            self.check_user_is_channel_admin(&channel, user_id, &tx)
-                .await?;
-
             let dev_server = dev_server::Entity::insert(dev_server::ActiveModel {
                 id: ActiveValue::NotSet,
                 hashed_token: ActiveValue::Set(hashed_access_token.to_string()),
-                channel_id: ActiveValue::Set(channel_id),
                 name: ActiveValue::Set(name.to_string()),
+                user_id: ActiveValue::Set(user_id),
             })
             .exec_with_returning(&*tx)
             .await?;
 
-            Ok((channel, dev_server))
+            let remote_projects = self.remote_projects_update_internal(user_id, &tx).await?;
+
+            Ok((dev_server, remote_projects))
+        })
+        .await
+    }
+
+    pub async fn delete_dev_server(
+        &self,
+        id: DevServerId,
+        user_id: UserId,
+    ) -> crate::Result<proto::RemoteProjectsUpdate> {
+        self.transaction(|tx| async move {
+            let Some(dev_server) = dev_server::Entity::find_by_id(id).one(&*tx).await? else {
+                return Err(anyhow::anyhow!("no dev server with id {}", id))?;
+            };
+            if dev_server.user_id != user_id {
+                return Err(anyhow::anyhow!(proto::ErrorCode::Forbidden))?;
+            }
+
+            remote_project::Entity::delete_many()
+                .filter(remote_project::Column::DevServerId.eq(id))
+                .exec(&*tx)
+                .await?;
+
+            dev_server::Entity::delete(dev_server.into_active_model())
+                .exec(&*tx)
+                .await?;
+
+            let remote_projects = self.remote_projects_update_internal(user_id, &tx).await?;
+
+            Ok(remote_projects)
         })
         .await
     }

crates/collab/src/db/queries/projects.rs 🔗

@@ -30,6 +30,7 @@ impl Database {
         room_id: RoomId,
         connection: ConnectionId,
         worktrees: &[proto::WorktreeMetadata],
+        remote_project_id: Option<RemoteProjectId>,
     ) -> Result<TransactionGuard<(ProjectId, proto::Room)>> {
         self.room_transaction(room_id, |tx| async move {
             let participant = room_participant::Entity::find()
@@ -58,6 +59,30 @@ impl Database {
                 return Err(anyhow!("guests cannot share projects"))?;
             }
 
+            if let Some(remote_project_id) = remote_project_id {
+                let project = project::Entity::find()
+                    .filter(project::Column::RemoteProjectId.eq(Some(remote_project_id)))
+                    .one(&*tx)
+                    .await?
+                    .ok_or_else(|| anyhow!("no remote project"))?;
+
+                if project.room_id.is_some() {
+                    return Err(anyhow!("project already shared"))?;
+                };
+
+                let project = project::Entity::update(project::ActiveModel {
+                    room_id: ActiveValue::Set(Some(room_id)),
+                    ..project.into_active_model()
+                })
+                .exec(&*tx)
+                .await?;
+
+                // todo! check user is a project-collaborator
+
+                let room = self.get_room(room_id, &tx).await?;
+                return Ok((project.id, room));
+            }
+
             let project = project::ActiveModel {
                 room_id: ActiveValue::set(Some(participant.room_id)),
                 host_user_id: ActiveValue::set(Some(participant.user_id)),
@@ -111,6 +136,7 @@ impl Database {
         &self,
         project_id: ProjectId,
         connection: ConnectionId,
+        user_id: Option<UserId>,
     ) -> Result<TransactionGuard<(Option<proto::Room>, Vec<ConnectionId>)>> {
         self.project_transaction(project_id, |tx| async move {
             let guest_connection_ids = self.project_guest_connection_ids(project_id, &tx).await?;
@@ -118,19 +144,37 @@ impl Database {
                 .one(&*tx)
                 .await?
                 .ok_or_else(|| anyhow!("project not found"))?;
+            let room = if let Some(room_id) = project.room_id {
+                Some(self.get_room(room_id, &tx).await?)
+            } else {
+                None
+            };
             if project.host_connection()? == connection {
-                let room = if let Some(room_id) = project.room_id {
-                    Some(self.get_room(room_id, &tx).await?)
-                } else {
-                    None
-                };
                 project::Entity::delete(project.into_active_model())
                     .exec(&*tx)
                     .await?;
-                Ok((room, guest_connection_ids))
-            } else {
-                Err(anyhow!("cannot unshare a project hosted by another user"))?
+                return Ok((room, guest_connection_ids));
+            }
+            if let Some(remote_project_id) = project.remote_project_id {
+                if let Some(user_id) = user_id {
+                    if user_id
+                        != self
+                            .owner_for_remote_project(remote_project_id, &tx)
+                            .await?
+                    {
+                        Err(anyhow!("cannot unshare a project hosted by another user"))?
+                    }
+                    project::Entity::update(project::ActiveModel {
+                        room_id: ActiveValue::Set(None),
+                        ..project.into_active_model()
+                    })
+                    .exec(&*tx)
+                    .await?;
+                    return Ok((room, guest_connection_ids));
+                }
             }
+
+            Err(anyhow!("cannot unshare a project hosted by another user"))?
         })
         .await
     }
@@ -753,6 +797,7 @@ impl Database {
                     name: language_server.name,
                 })
                 .collect(),
+            remote_project_id: project.remote_project_id,
         };
         Ok((project, replica_id as ReplicaId))
     }
@@ -794,8 +839,7 @@ impl Database {
             Ok(LeftProject {
                 id: project.id,
                 connection_ids,
-                host_user_id: None,
-                host_connection_id: None,
+                should_unshare: false,
             })
         })
         .await
@@ -832,7 +876,7 @@ impl Database {
                 .find_related(project_collaborator::Entity)
                 .all(&*tx)
                 .await?;
-            let connection_ids = collaborators
+            let connection_ids: Vec<ConnectionId> = collaborators
                 .into_iter()
                 .map(|collaborator| collaborator.connection())
                 .collect();
@@ -870,8 +914,7 @@ impl Database {
 
             let left_project = LeftProject {
                 id: project_id,
-                host_user_id: project.host_user_id,
-                host_connection_id: Some(project.host_connection()?),
+                should_unshare: connection == project.host_connection()?,
                 connection_ids,
             };
             Ok((room, left_project))
@@ -914,7 +957,7 @@ impl Database {
         capability: Capability,
         tx: &DatabaseTransaction,
     ) -> Result<(project::Model, ChannelRole)> {
-        let (project, remote_project) = project::Entity::find_by_id(project_id)
+        let (mut project, remote_project) = project::Entity::find_by_id(project_id)
             .find_also_related(remote_project::Entity)
             .one(tx)
             .await?
@@ -933,28 +976,45 @@ impl Database {
             PrincipalId::UserId(user_id) => user_id,
         };
 
-        let role = if let Some(remote_project) = remote_project {
-            let channel = channel::Entity::find_by_id(remote_project.channel_id)
-                .one(tx)
-                .await?
-                .ok_or_else(|| anyhow!("no such channel"))?;
-
-            self.check_user_is_channel_participant(&channel, user_id, &tx)
-                .await?
-        } else if let Some(room_id) = project.room_id {
-            // what's the users role?
-            let current_participant = room_participant::Entity::find()
+        let role_from_room = if let Some(room_id) = project.room_id {
+            room_participant::Entity::find()
                 .filter(room_participant::Column::RoomId.eq(room_id))
                 .filter(room_participant::Column::AnsweringConnectionId.eq(connection_id.id))
                 .one(tx)
                 .await?
-                .ok_or_else(|| anyhow!("no such room"))?;
-
-            current_participant.role.unwrap_or(ChannelRole::Guest)
+                .and_then(|participant| participant.role)
+        } else {
+            None
+        };
+        let role_from_remote_project = if let Some(remote_project) = remote_project {
+            let dev_server = dev_server::Entity::find_by_id(remote_project.dev_server_id)
+                .one(tx)
+                .await?
+                .ok_or_else(|| anyhow!("no such channel"))?;
+            if user_id == dev_server.user_id {
+                // If the user left the room "uncleanly" they may rejoin the
+                // remote project before leave_room runs. IN that case kick
+                // the project out of the room pre-emptively.
+                if role_from_room.is_none() {
+                    project = project::Entity::update(project::ActiveModel {
+                        room_id: ActiveValue::Set(None),
+                        ..project.into_active_model()
+                    })
+                    .exec(tx)
+                    .await?;
+                }
+                Some(ChannelRole::Admin)
+            } else {
+                None
+            }
         } else {
-            return Err(anyhow!("not authorized to read projects"))?;
+            None
         };
 
+        let role = role_from_remote_project
+            .or(role_from_room)
+            .unwrap_or(ChannelRole::Banned);
+
         match capability {
             Capability::ReadWrite => {
                 if !role.can_edit_projects() {

crates/collab/src/db/queries/remote_projects.rs 🔗

@@ -8,8 +8,8 @@ use sea_orm::{
 use crate::db::ProjectId;
 
 use super::{
-    channel, project, project_collaborator, remote_project, worktree, ChannelId, Database,
-    DevServerId, RejoinedProject, RemoteProjectId, ResharedProject, ServerId, UserId,
+    dev_server, project, project_collaborator, remote_project, worktree, Database, DevServerId,
+    RejoinedProject, RemoteProjectId, ResharedProject, ServerId, UserId,
 };
 
 impl Database {
@@ -26,29 +26,6 @@ impl Database {
         .await
     }
 
-    pub async fn get_remote_projects(
-        &self,
-        channel_ids: &Vec<ChannelId>,
-        tx: &DatabaseTransaction,
-    ) -> crate::Result<Vec<proto::RemoteProject>> {
-        let servers = remote_project::Entity::find()
-            .filter(remote_project::Column::ChannelId.is_in(channel_ids.iter().map(|id| id.0)))
-            .find_also_related(project::Entity)
-            .all(tx)
-            .await?;
-        Ok(servers
-            .into_iter()
-            .map(|(remote_project, project)| proto::RemoteProject {
-                id: remote_project.id.to_proto(),
-                project_id: project.map(|p| p.id.to_proto()),
-                channel_id: remote_project.channel_id.to_proto(),
-                name: remote_project.name,
-                dev_server_id: remote_project.dev_server_id.to_proto(),
-                path: remote_project.path,
-            })
-            .collect())
-    }
-
     pub async fn get_remote_projects_for_dev_server(
         &self,
         dev_server_id: DevServerId,
@@ -64,8 +41,6 @@ impl Database {
                 .map(|(remote_project, project)| proto::RemoteProject {
                     id: remote_project.id.to_proto(),
                     project_id: project.map(|p| p.id.to_proto()),
-                    channel_id: remote_project.channel_id.to_proto(),
-                    name: remote_project.name,
                     dev_server_id: remote_project.dev_server_id.to_proto(),
                     path: remote_project.path,
                 })
@@ -74,6 +49,38 @@ impl Database {
         .await
     }
 
+    pub async fn remote_project_ids_for_user(
+        &self,
+        user_id: UserId,
+        tx: &DatabaseTransaction,
+    ) -> crate::Result<Vec<RemoteProjectId>> {
+        let dev_servers = dev_server::Entity::find()
+            .filter(dev_server::Column::UserId.eq(user_id))
+            .find_with_related(remote_project::Entity)
+            .all(tx)
+            .await?;
+
+        Ok(dev_servers
+            .into_iter()
+            .flat_map(|(_, projects)| projects.into_iter().map(|p| p.id))
+            .collect())
+    }
+
+    pub async fn owner_for_remote_project(
+        &self,
+        remote_project_id: RemoteProjectId,
+        tx: &DatabaseTransaction,
+    ) -> crate::Result<UserId> {
+        let dev_server = remote_project::Entity::find_by_id(remote_project_id)
+            .find_also_related(dev_server::Entity)
+            .one(tx)
+            .await?
+            .and_then(|(_, dev_server)| dev_server)
+            .ok_or_else(|| anyhow!("no remote project"))?;
+
+        Ok(dev_server.user_id)
+    }
+
     pub async fn get_stale_dev_server_projects(
         &self,
         connection: ConnectionId,
@@ -95,28 +102,30 @@ impl Database {
 
     pub async fn create_remote_project(
         &self,
-        channel_id: ChannelId,
         dev_server_id: DevServerId,
-        name: &str,
         path: &str,
         user_id: UserId,
-    ) -> crate::Result<(channel::Model, remote_project::Model)> {
+    ) -> crate::Result<(remote_project::Model, proto::RemoteProjectsUpdate)> {
         self.transaction(|tx| async move {
-            let channel = self.get_channel_internal(channel_id, &tx).await?;
-            self.check_user_is_channel_admin(&channel, user_id, &tx)
-                .await?;
+            let dev_server = dev_server::Entity::find_by_id(dev_server_id)
+                .one(&*tx)
+                .await?
+                .ok_or_else(|| anyhow!("no dev server with id {}", dev_server_id))?;
+            if dev_server.user_id != user_id {
+                return Err(anyhow!("not your dev server"))?;
+            }
 
             let project = remote_project::Entity::insert(remote_project::ActiveModel {
-                name: ActiveValue::Set(name.to_string()),
                 id: ActiveValue::NotSet,
-                channel_id: ActiveValue::Set(channel_id),
                 dev_server_id: ActiveValue::Set(dev_server_id),
                 path: ActiveValue::Set(path.to_string()),
             })
             .exec_with_returning(&*tx)
             .await?;
 
-            Ok((channel, project))
+            let status = self.remote_projects_update_internal(user_id, &tx).await?;
+
+            Ok((project, status))
         })
         .await
     }
@@ -127,8 +136,13 @@ impl Database {
         dev_server_id: DevServerId,
         connection: ConnectionId,
         worktrees: &[proto::WorktreeMetadata],
-    ) -> crate::Result<proto::RemoteProject> {
+    ) -> crate::Result<(proto::RemoteProject, UserId, proto::RemoteProjectsUpdate)> {
         self.transaction(|tx| async move {
+            let dev_server = dev_server::Entity::find_by_id(dev_server_id)
+                .one(&*tx)
+                .await?
+                .ok_or_else(|| anyhow!("no dev server with id {}", dev_server_id))?;
+
             let remote_project = remote_project::Entity::find_by_id(remote_project_id)
                 .one(&*tx)
                 .await?
@@ -168,7 +182,15 @@ impl Database {
                 .await?;
             }
 
-            Ok(remote_project.to_proto(Some(project)))
+            let status = self
+                .remote_projects_update_internal(dev_server.user_id, &tx)
+                .await?;
+
+            Ok((
+                remote_project.to_proto(Some(project)),
+                dev_server.user_id,
+                status,
+            ))
         })
         .await
     }

crates/collab/src/db/queries/rooms.rs 🔗

@@ -849,11 +849,32 @@ impl Database {
                     .into_values::<_, QueryProjectIds>()
                     .all(&*tx)
                     .await?;
+
+                // if any project in the room has a remote-project-id that belongs to a dev server that this user owns.
+                let remote_projects_for_user = self
+                    .remote_project_ids_for_user(leaving_participant.user_id, &tx)
+                    .await?;
+
+                let remote_projects_to_unshare = project::Entity::find()
+                    .filter(
+                        Condition::all()
+                            .add(project::Column::RoomId.eq(room_id))
+                            .add(
+                                project::Column::RemoteProjectId
+                                    .is_in(remote_projects_for_user.clone()),
+                            ),
+                    )
+                    .all(&*tx)
+                    .await?
+                    .into_iter()
+                    .map(|project| project.id)
+                    .collect::<HashSet<_>>();
                 let mut left_projects = HashMap::default();
                 let mut collaborators = project_collaborator::Entity::find()
                     .filter(project_collaborator::Column::ProjectId.is_in(project_ids))
                     .stream(&*tx)
                     .await?;
+
                 while let Some(collaborator) = collaborators.next().await {
                     let collaborator = collaborator?;
                     let left_project =
@@ -861,9 +882,8 @@ impl Database {
                             .entry(collaborator.project_id)
                             .or_insert(LeftProject {
                                 id: collaborator.project_id,
-                                host_user_id: Default::default(),
                                 connection_ids: Default::default(),
-                                host_connection_id: None,
+                                should_unshare: false,
                             });
 
                     let collaborator_connection_id = collaborator.connection();
@@ -871,9 +891,10 @@ impl Database {
                         left_project.connection_ids.push(collaborator_connection_id);
                     }
 
-                    if collaborator.is_host {
-                        left_project.host_user_id = Some(collaborator.user_id);
-                        left_project.host_connection_id = Some(collaborator_connection_id);
+                    if (collaborator.is_host && collaborator.connection() == connection)
+                        || remote_projects_to_unshare.contains(&collaborator.project_id)
+                    {
+                        left_project.should_unshare = true;
                     }
                 }
                 drop(collaborators);
@@ -915,6 +936,17 @@ impl Database {
                     .exec(&*tx)
                     .await?;
 
+                if !remote_projects_to_unshare.is_empty() {
+                    project::Entity::update_many()
+                        .filter(project::Column::Id.is_in(remote_projects_to_unshare))
+                        .set(project::ActiveModel {
+                            room_id: ActiveValue::Set(None),
+                            ..Default::default()
+                        })
+                        .exec(&*tx)
+                        .await?;
+                }
+
                 let (channel, room) = self.get_channel_room(room_id, &tx).await?;
                 let deleted = if room.participants.is_empty() {
                     let result = room::Entity::delete_by_id(room_id).exec(&*tx).await?;
@@ -1264,38 +1296,46 @@ impl Database {
         }
         drop(db_participants);
 
-        let mut db_projects = db_room
+        let db_projects = db_room
             .find_related(project::Entity)
             .find_with_related(worktree::Entity)
-            .stream(tx)
+            .all(tx)
             .await?;
 
-        while let Some(row) = db_projects.next().await {
-            let (db_project, db_worktree) = row?;
+        for (db_project, db_worktrees) in db_projects {
             let host_connection = db_project.host_connection()?;
             if let Some(participant) = participants.get_mut(&host_connection) {
-                let project = if let Some(project) = participant
-                    .projects
+                participant.projects.push(proto::ParticipantProject {
+                    id: db_project.id.to_proto(),
+                    worktree_root_names: Default::default(),
+                });
+                let project = participant.projects.last_mut().unwrap();
+
+                for db_worktree in db_worktrees {
+                    if db_worktree.visible {
+                        project.worktree_root_names.push(db_worktree.root_name);
+                    }
+                }
+            } else if let Some(remote_project_id) = db_project.remote_project_id {
+                let host = self.owner_for_remote_project(remote_project_id, tx).await?;
+                if let Some((_, participant)) = participants
                     .iter_mut()
-                    .find(|project| project.id == db_project.id.to_proto())
+                    .find(|(_, v)| v.user_id == host.to_proto())
                 {
-                    project
-                } else {
                     participant.projects.push(proto::ParticipantProject {
                         id: db_project.id.to_proto(),
                         worktree_root_names: Default::default(),
                     });
-                    participant.projects.last_mut().unwrap()
-                };
+                    let project = participant.projects.last_mut().unwrap();
 
-                if let Some(db_worktree) = db_worktree {
-                    if db_worktree.visible {
-                        project.worktree_root_names.push(db_worktree.root_name);
+                    for db_worktree in db_worktrees {
+                        if db_worktree.visible {
+                            project.worktree_root_names.push(db_worktree.root_name);
+                        }
                     }
                 }
             }
         }
-        drop(db_projects);
 
         let mut db_followers = db_room.find_related(follower::Entity).stream(tx).await?;
         let mut followers = Vec::new();

crates/collab/src/db/tables/dev_server.rs 🔗

@@ -1,4 +1,4 @@
-use crate::db::{ChannelId, DevServerId};
+use crate::db::{DevServerId, UserId};
 use rpc::proto;
 use sea_orm::entity::prelude::*;
 
@@ -8,20 +8,28 @@ pub struct Model {
     #[sea_orm(primary_key)]
     pub id: DevServerId,
     pub name: String,
-    pub channel_id: ChannelId,
+    pub user_id: UserId,
     pub hashed_token: String,
 }
 
 impl ActiveModelBehavior for ActiveModel {}
 
 #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
-pub enum Relation {}
+pub enum Relation {
+    #[sea_orm(has_many = "super::remote_project::Entity")]
+    RemoteProject,
+}
+
+impl Related<super::remote_project::Entity> for Entity {
+    fn to() -> RelationDef {
+        Relation::RemoteProject.def()
+    }
+}
 
 impl Model {
     pub fn to_proto(&self, status: proto::DevServerStatus) -> proto::DevServer {
         proto::DevServer {
             dev_server_id: self.id.to_proto(),
-            channel_id: self.channel_id.to_proto(),
             name: self.name.clone(),
             status: status as i32,
         }

crates/collab/src/db/tables/remote_project.rs 🔗

@@ -1,5 +1,5 @@
 use super::project;
-use crate::db::{ChannelId, DevServerId, RemoteProjectId};
+use crate::db::{DevServerId, RemoteProjectId};
 use rpc::proto;
 use sea_orm::entity::prelude::*;
 
@@ -8,9 +8,7 @@ use sea_orm::entity::prelude::*;
 pub struct Model {
     #[sea_orm(primary_key)]
     pub id: RemoteProjectId,
-    pub channel_id: ChannelId,
     pub dev_server_id: DevServerId,
-    pub name: String,
     pub path: String,
 }
 
@@ -20,6 +18,12 @@ impl ActiveModelBehavior for ActiveModel {}
 pub enum Relation {
     #[sea_orm(has_one = "super::project::Entity")]
     Project,
+    #[sea_orm(
+        belongs_to = "super::dev_server::Entity",
+        from = "Column::DevServerId",
+        to = "super::dev_server::Column::Id"
+    )]
+    DevServer,
 }
 
 impl Related<super::project::Entity> for Entity {
@@ -28,14 +32,18 @@ impl Related<super::project::Entity> for Entity {
     }
 }
 
+impl Related<super::dev_server::Entity> for Entity {
+    fn to() -> RelationDef {
+        Relation::DevServer.def()
+    }
+}
+
 impl Model {
     pub fn to_proto(&self, project: Option<project::Model>) -> proto::RemoteProject {
         proto::RemoteProject {
             id: self.id.to_proto(),
             project_id: project.map(|p| p.id.to_proto()),
-            channel_id: self.channel_id.to_proto(),
             dev_server_id: self.dev_server_id.to_proto(),
-            name: self.name.clone(),
             path: self.path.clone(),
         }
     }

crates/collab/src/db/tests/db_tests.rs 🔗

@@ -535,18 +535,18 @@ async fn test_project_count(db: &Arc<Database>) {
         .unwrap();
     assert_eq!(db.project_count_excluding_admins().await.unwrap(), 0);
 
-    db.share_project(room_id, ConnectionId { owner_id, id: 1 }, &[])
+    db.share_project(room_id, ConnectionId { owner_id, id: 1 }, &[], None)
         .await
         .unwrap();
     assert_eq!(db.project_count_excluding_admins().await.unwrap(), 1);
 
-    db.share_project(room_id, ConnectionId { owner_id, id: 1 }, &[])
+    db.share_project(room_id, ConnectionId { owner_id, id: 1 }, &[], None)
         .await
         .unwrap();
     assert_eq!(db.project_count_excluding_admins().await.unwrap(), 2);
 
     // Projects shared by admins aren't counted.
-    db.share_project(room_id, ConnectionId { owner_id, id: 0 }, &[])
+    db.share_project(room_id, ConnectionId { owner_id, id: 0 }, &[], None)
         .await
         .unwrap();
     assert_eq!(db.project_count_excluding_admins().await.unwrap(), 2);

crates/collab/src/rpc.rs 🔗

@@ -255,6 +255,13 @@ impl DevServerSession {
     pub fn dev_server_id(&self) -> DevServerId {
         self.0.dev_server_id().unwrap()
     }
+
+    fn dev_server(&self) -> &dev_server::Model {
+        match &self.0.principal {
+            Principal::DevServer(dev_server) => dev_server,
+            _ => unreachable!(),
+        }
+    }
 }
 
 impl Deref for DevServerSession {
@@ -405,6 +412,7 @@ impl Server {
             .add_request_handler(user_handler(rejoin_remote_projects))
             .add_request_handler(user_handler(create_remote_project))
             .add_request_handler(user_handler(create_dev_server))
+            .add_request_handler(user_handler(delete_dev_server))
             .add_request_handler(dev_server_handler(share_remote_project))
             .add_request_handler(dev_server_handler(shutdown_dev_server))
             .add_request_handler(dev_server_handler(reconnect_dev_server))
@@ -1044,12 +1052,14 @@ impl Server {
                         .await?;
                 }
 
-                let (contacts, channels_for_user, channel_invites) = future::try_join3(
-                    self.app_state.db.get_contacts(user.id),
-                    self.app_state.db.get_channels_for_user(user.id),
-                    self.app_state.db.get_channel_invites_for_user(user.id),
-                )
-                .await?;
+                let (contacts, channels_for_user, channel_invites, remote_projects) =
+                    future::try_join4(
+                        self.app_state.db.get_contacts(user.id),
+                        self.app_state.db.get_channels_for_user(user.id),
+                        self.app_state.db.get_channel_invites_for_user(user.id),
+                        self.app_state.db.remote_projects_update(user.id),
+                    )
+                    .await?;
 
                 {
                     let mut pool = self.connection_pool.lock();
@@ -1067,9 +1077,10 @@ impl Server {
                     )?;
                     self.peer.send(
                         connection_id,
-                        build_channels_update(channels_for_user, channel_invites, &pool),
+                        build_channels_update(channels_for_user, channel_invites),
                     )?;
                 }
+                send_remote_projects_update(user.id, remote_projects, session).await;
 
                 if let Some(incoming_call) =
                     self.app_state.db.incoming_call_for_user(user.id).await?
@@ -1087,9 +1098,6 @@ impl Server {
                     };
                     pool.add_dev_server(connection_id, dev_server.id, zed_version);
                 }
-                update_dev_server_status(dev_server, proto::DevServerStatus::Online, &session)
-                    .await;
-                // todo!() allow only one connection.
 
                 let projects = self
                     .app_state
@@ -1098,6 +1106,13 @@ impl Server {
                     .await?;
                 self.peer
                     .send(connection_id, proto::DevServerInstructions { projects })?;
+
+                let status = self
+                    .app_state
+                    .db
+                    .remote_projects_update(dev_server.user_id)
+                    .await?;
+                send_remote_projects_update(dev_server.user_id, status, &session).await;
             }
         }
 
@@ -1401,10 +1416,8 @@ async fn connection_lost(
 
                     update_user_contacts(session.user_id(), &session).await?;
                 },
-            Principal::DevServer(dev_server) => {
-                lost_dev_server_connection(&session).await?;
-                update_dev_server_status(&dev_server, proto::DevServerStatus::Offline, &session)
-                    .await;
+            Principal::DevServer(_) => {
+                lost_dev_server_connection(&session.for_dev_server().unwrap()).await?;
             },
         }
         },
@@ -1941,6 +1954,9 @@ async fn share_project(
             RoomId::from_proto(request.room_id),
             session.connection_id,
             &request.worktrees,
+            request
+                .remote_project_id
+                .map(|id| RemoteProjectId::from_proto(id)),
         )
         .await?;
     response.send(proto::ShareProjectResponse {
@@ -1954,14 +1970,25 @@ async fn share_project(
 /// Unshare a project from the room.
 async fn unshare_project(message: proto::UnshareProject, session: Session) -> Result<()> {
     let project_id = ProjectId::from_proto(message.project_id);
-    unshare_project_internal(project_id, &session).await
+    unshare_project_internal(
+        project_id,
+        session.connection_id,
+        session.user_id(),
+        &session,
+    )
+    .await
 }
 
-async fn unshare_project_internal(project_id: ProjectId, session: &Session) -> Result<()> {
+async fn unshare_project_internal(
+    project_id: ProjectId,
+    connection_id: ConnectionId,
+    user_id: Option<UserId>,
+    session: &Session,
+) -> Result<()> {
     let (room, guest_connection_ids) = &*session
         .db()
         .await
-        .unshare_project(project_id, session.connection_id)
+        .unshare_project(project_id, connection_id, user_id)
         .await?;
 
     let message = proto::UnshareProject {
@@ -1969,7 +1996,7 @@ async fn unshare_project_internal(project_id: ProjectId, session: &Session) -> R
     };
 
     broadcast(
-        Some(session.connection_id),
+        Some(connection_id),
         guest_connection_ids.iter().copied(),
         |conn_id| session.peer.send(conn_id, message.clone()),
     );
@@ -1980,13 +2007,13 @@ async fn unshare_project_internal(project_id: ProjectId, session: &Session) -> R
     Ok(())
 }
 
-/// Share a project into the room.
+/// DevServer makes a project available online
 async fn share_remote_project(
     request: proto::ShareRemoteProject,
     response: Response<proto::ShareRemoteProject>,
     session: DevServerSession,
 ) -> Result<()> {
-    let remote_project = session
+    let (remote_project, user_id, status) = session
         .db()
         .await
         .share_remote_project(
@@ -2000,22 +2027,7 @@ async fn share_remote_project(
         return Err(anyhow!("failed to share remote project"))?;
     };
 
-    for (connection_id, _) in session
-        .connection_pool()
-        .await
-        .channel_connection_ids(ChannelId::from_proto(remote_project.channel_id))
-    {
-        session
-            .peer
-            .send(
-                connection_id,
-                proto::UpdateChannels {
-                    remote_projects: vec![remote_project.clone()],
-                    ..Default::default()
-                },
-            )
-            .trace_err();
-    }
+    send_remote_projects_update(user_id, status, &session).await;
 
     response.send(proto::ShareProjectResponse { project_id })?;
 
@@ -2081,19 +2093,21 @@ fn join_project_internal(
         })
         .collect::<Vec<_>>();
 
+    let add_project_collaborator = proto::AddProjectCollaborator {
+        project_id: project_id.to_proto(),
+        collaborator: Some(proto::Collaborator {
+            peer_id: Some(session.connection_id.into()),
+            replica_id: replica_id.0 as u32,
+            user_id: guest_user_id.to_proto(),
+        }),
+    };
+
     for collaborator in &collaborators {
         session
             .peer
             .send(
                 collaborator.peer_id.unwrap().into(),
-                proto::AddProjectCollaborator {
-                    project_id: project_id.to_proto(),
-                    collaborator: Some(proto::Collaborator {
-                        peer_id: Some(session.connection_id.into()),
-                        replica_id: replica_id.0 as u32,
-                        user_id: guest_user_id.to_proto(),
-                    }),
-                },
+                add_project_collaborator.clone(),
             )
             .trace_err();
     }
@@ -2105,7 +2119,10 @@ fn join_project_internal(
         replica_id: replica_id.0 as u32,
         collaborators: collaborators.clone(),
         language_servers: project.language_servers.clone(),
-        role: project.role.into(), // todo
+        role: project.role.into(),
+        remote_project_id: project
+            .remote_project_id
+            .map(|remote_project_id| remote_project_id.0 as u64),
     })?;
 
     for (worktree_id, worktree) in mem::take(&mut project.worktrees) {
@@ -2188,8 +2205,6 @@ async fn leave_project(request: proto::LeaveProject, session: UserSession) -> Re
     let (room, project) = &*db.leave_project(project_id, sender_id).await?;
     tracing::info!(
         %project_id,
-        host_user_id = ?project.host_user_id,
-        host_connection_id = ?project.host_connection_id,
         "leave project"
     );
 
@@ -2224,13 +2239,33 @@ async fn create_remote_project(
     response: Response<proto::CreateRemoteProject>,
     session: UserSession,
 ) -> Result<()> {
-    let (channel, remote_project) = session
+    let dev_server_id = DevServerId(request.dev_server_id as i32);
+    let dev_server_connection_id = session
+        .connection_pool()
+        .await
+        .dev_server_connection_id(dev_server_id);
+    let Some(dev_server_connection_id) = dev_server_connection_id else {
+        Err(ErrorCode::DevServerOffline
+            .message("Cannot create a remote project when the dev server is offline".to_string())
+            .anyhow())?
+    };
+
+    let path = request.path.clone();
+    //Check that the path exists on the dev server
+    session
+        .peer
+        .forward_request(
+            session.connection_id,
+            dev_server_connection_id,
+            proto::ValidateRemoteProjectRequest { path: path.clone() },
+        )
+        .await?;
+
+    let (remote_project, update) = session
         .db()
         .await
         .create_remote_project(
-            ChannelId(request.channel_id as i32),
             DevServerId(request.dev_server_id as i32),
-            &request.name,
             &request.path,
             session.user_id(),
         )
@@ -2242,25 +2277,12 @@ async fn create_remote_project(
         .get_remote_projects_for_dev_server(remote_project.dev_server_id)
         .await?;
 
-    let update = proto::UpdateChannels {
-        remote_projects: vec![remote_project.to_proto(None)],
-        ..Default::default()
-    };
-    let connection_pool = session.connection_pool().await;
-    for (connection_id, role) in connection_pool.channel_connection_ids(channel.root_id()) {
-        if role.can_see_all_descendants() {
-            session.peer.send(connection_id, update.clone())?;
-        }
-    }
+    session.peer.send(
+        dev_server_connection_id,
+        proto::DevServerInstructions { projects },
+    )?;
 
-    let dev_server_id = remote_project.dev_server_id;
-    let dev_server_connection_id = connection_pool.dev_server_connection_id(dev_server_id);
-    if let Some(dev_server_connection_id) = dev_server_connection_id {
-        session.peer.send(
-            dev_server_connection_id,
-            proto::DevServerInstructions { projects },
-        )?;
-    }
+    send_remote_projects_update(session.user_id(), update, &session).await;
 
     response.send(proto::CreateRemoteProjectResponse {
         remote_project: Some(remote_project.to_proto(None)),
@@ -2276,37 +2298,56 @@ async fn create_dev_server(
     let access_token = auth::random_token();
     let hashed_access_token = auth::hash_access_token(&access_token);
 
-    let (channel, dev_server) = session
+    let (dev_server, status) = session
         .db()
         .await
-        .create_dev_server(
-            ChannelId(request.channel_id as i32),
-            &request.name,
-            &hashed_access_token,
-            session.user_id(),
-        )
+        .create_dev_server(&request.name, &hashed_access_token, session.user_id())
         .await?;
 
-    let update = proto::UpdateChannels {
-        dev_servers: vec![dev_server.to_proto(proto::DevServerStatus::Offline)],
-        ..Default::default()
-    };
-    let connection_pool = session.connection_pool().await;
-    for (connection_id, role) in connection_pool.channel_connection_ids(channel.root_id()) {
-        if role.can_see_channel(channel.visibility) {
-            session.peer.send(connection_id, update.clone())?;
-        }
-    }
+    send_remote_projects_update(session.user_id(), status, &session).await;
 
     response.send(proto::CreateDevServerResponse {
         dev_server_id: dev_server.id.0 as u64,
-        channel_id: request.channel_id,
         access_token: auth::generate_dev_server_token(dev_server.id.0 as usize, access_token),
         name: request.name.clone(),
     })?;
     Ok(())
 }
 
+async fn delete_dev_server(
+    request: proto::DeleteDevServer,
+    response: Response<proto::DeleteDevServer>,
+    session: UserSession,
+) -> Result<()> {
+    let dev_server_id = DevServerId(request.dev_server_id as i32);
+    let dev_server = session.db().await.get_dev_server(dev_server_id).await?;
+    if dev_server.user_id != session.user_id() {
+        return Err(anyhow!(ErrorCode::Forbidden))?;
+    }
+
+    let connection_id = session
+        .connection_pool()
+        .await
+        .dev_server_connection_id(dev_server_id);
+    if let Some(connection_id) = connection_id {
+        shutdown_dev_server_internal(dev_server_id, connection_id, &session).await?;
+        session
+            .peer
+            .send(connection_id, proto::ShutdownDevServer {})?;
+    }
+
+    let status = session
+        .db()
+        .await
+        .delete_dev_server(dev_server_id, session.user_id())
+        .await?;
+
+    send_remote_projects_update(session.user_id(), status, &session).await;
+
+    response.send(proto::Ack {})?;
+    Ok(())
+}
+
 async fn rejoin_remote_projects(
     request: proto::RejoinRemoteProjects,
     response: Response<proto::RejoinRemoteProjects>,
@@ -2403,8 +2444,15 @@ async fn shutdown_dev_server(
     session: DevServerSession,
 ) -> Result<()> {
     response.send(proto::Ack {})?;
+    shutdown_dev_server_internal(session.dev_server_id(), session.connection_id, &session).await
+}
+
+async fn shutdown_dev_server_internal(
+    dev_server_id: DevServerId,
+    connection_id: ConnectionId,
+    session: &Session,
+) -> Result<()> {
     let (remote_projects, dev_server) = {
-        let dev_server_id = session.dev_server_id();
         let db = session.db().await;
         let remote_projects = db.get_remote_projects_for_dev_server(dev_server_id).await?;
         let dev_server = db.get_dev_server(dev_server_id).await?;
@@ -2412,22 +2460,26 @@ async fn shutdown_dev_server(
     };
 
     for project_id in remote_projects.iter().filter_map(|p| p.project_id) {
-        unshare_project_internal(ProjectId::from_proto(project_id), &session.0).await?;
+        unshare_project_internal(
+            ProjectId::from_proto(project_id),
+            connection_id,
+            None,
+            session,
+        )
+        .await?;
     }
 
-    let update = proto::UpdateChannels {
-        remote_projects,
-        dev_servers: vec![dev_server.to_proto(proto::DevServerStatus::Offline)],
-        ..Default::default()
-    };
-
-    for (connection_id, _) in session
+    session
         .connection_pool()
         .await
-        .channel_connection_ids(dev_server.channel_id)
-    {
-        session.peer.send(connection_id, update.clone()).trace_err();
-    }
+        .set_dev_server_offline(dev_server_id);
+
+    let status = session
+        .db()
+        .await
+        .remote_projects_update(dev_server.user_id)
+        .await?;
+    send_remote_projects_update(dev_server.user_id, status, &session).await;
 
     Ok(())
 }
@@ -4626,7 +4678,7 @@ fn notify_membership_updated(
         ..Default::default()
     };
 
-    let mut update = build_channels_update(result.new_channels, vec![], connection_pool);
+    let mut update = build_channels_update(result.new_channels, vec![]);
     update.delete_channels = result
         .removed_channels
         .into_iter()
@@ -4659,7 +4711,6 @@ fn build_update_user_channels(channels: &ChannelsForUser) -> proto::UpdateUserCh
 fn build_channels_update(
     channels: ChannelsForUser,
     channel_invites: Vec<db::Channel>,
-    pool: &ConnectionPool,
 ) -> proto::UpdateChannels {
     let mut update = proto::UpdateChannels::default();
 
@@ -4684,13 +4735,6 @@ fn build_channels_update(
     }
 
     update.hosted_projects = channels.hosted_projects;
-    update.dev_servers = channels
-        .dev_servers
-        .into_iter()
-        .map(|dev_server| dev_server.to_proto(pool.dev_server_status(dev_server.id)))
-        .collect();
-    update.remote_projects = channels.remote_projects;
-
     update
 }
 
@@ -4777,24 +4821,19 @@ fn channel_updated(
     );
 }
 
-async fn update_dev_server_status(
-    dev_server: &dev_server::Model,
-    status: proto::DevServerStatus,
+async fn send_remote_projects_update(
+    user_id: UserId,
+    mut status: proto::RemoteProjectsUpdate,
     session: &Session,
 ) {
     let pool = session.connection_pool().await;
-    let connections = pool.channel_connection_ids(dev_server.channel_id);
-    for (connection_id, _) in connections {
-        session
-            .peer
-            .send(
-                connection_id,
-                proto::UpdateChannels {
-                    dev_servers: vec![dev_server.to_proto(status)],
-                    ..Default::default()
-                },
-            )
-            .trace_err();
+    for dev_server in &mut status.dev_servers {
+        dev_server.status =
+            pool.dev_server_status(DevServerId(dev_server.dev_server_id as i32)) as i32;
+    }
+    let connections = pool.user_connection_ids(user_id);
+    for connection_id in connections {
+        session.peer.send(connection_id, status.clone()).trace_err();
     }
 }
 
@@ -4833,7 +4872,7 @@ async fn update_user_contacts(user_id: UserId, session: &Session) -> Result<()>
     Ok(())
 }
 
-async fn lost_dev_server_connection(session: &Session) -> Result<()> {
+async fn lost_dev_server_connection(session: &DevServerSession) -> Result<()> {
     log::info!("lost dev server connection, unsharing projects");
     let project_ids = session
         .db()
@@ -4843,9 +4882,14 @@ async fn lost_dev_server_connection(session: &Session) -> Result<()> {
 
     for project_id in project_ids {
         // not unshare re-checks the connection ids match, so we get away with no transaction
-        unshare_project_internal(project_id, &session).await?;
+        unshare_project_internal(project_id, session.connection_id, None, &session).await?;
     }
 
+    let user_id = session.dev_server().user_id;
+    let update = session.db().await.remote_projects_update(user_id).await?;
+
+    send_remote_projects_update(user_id, update, session).await;
+
     Ok(())
 }
 
@@ -4947,7 +4991,7 @@ async fn leave_channel_buffers_for_session(session: &Session) -> Result<()> {
 
 fn project_left(project: &db::LeftProject, session: &UserSession) {
     for connection_id in &project.connection_ids {
-        if project.host_user_id == Some(session.user_id()) {
+        if project.should_unshare {
             session
                 .peer
                 .send(

crates/collab/src/rpc/connection_pool.rs 🔗

@@ -13,6 +13,7 @@ pub struct ConnectionPool {
     connected_users: BTreeMap<UserId, ConnectedPrincipal>,
     connected_dev_servers: BTreeMap<DevServerId, ConnectionId>,
     channels: ChannelPool,
+    offline_dev_servers: HashSet<DevServerId>,
 }
 
 #[derive(Default, Serialize)]
@@ -106,12 +107,17 @@ impl ConnectionPool {
             }
             PrincipalId::DevServerId(dev_server_id) => {
                 self.connected_dev_servers.remove(&dev_server_id);
+                self.offline_dev_servers.remove(&dev_server_id);
             }
         }
         self.connections.remove(&connection_id).unwrap();
         Ok(())
     }
 
+    pub fn set_dev_server_offline(&mut self, dev_server_id: DevServerId) {
+        self.offline_dev_servers.insert(dev_server_id);
+    }
+
     pub fn connections(&self) -> impl Iterator<Item = &Connection> {
         self.connections.values()
     }
@@ -137,7 +143,9 @@ impl ConnectionPool {
     }
 
     pub fn dev_server_status(&self, dev_server_id: DevServerId) -> proto::DevServerStatus {
-        if self.dev_server_connection_id(dev_server_id).is_some() {
+        if self.dev_server_connection_id(dev_server_id).is_some()
+            && !self.offline_dev_servers.contains(&dev_server_id)
+        {
             proto::DevServerStatus::Online
         } else {
             proto::DevServerStatus::Offline

crates/collab/src/tests/channel_tests.rs 🔗

@@ -1023,6 +1023,8 @@ async fn test_channel_link_notifications(
         .await
         .unwrap();
 
+    executor.run_until_parked();
+
     // the new channel shows for b and c
     assert_channels_list_shape(
         client_a.channel_store(),

crates/collab/src/tests/dev_server_tests.rs 🔗

@@ -1,45 +1,40 @@
-use std::path::Path;
+use std::{path::Path, sync::Arc};
 
+use call::ActiveCall;
 use editor::Editor;
 use fs::Fs;
-use gpui::VisualTestContext;
-use rpc::proto::DevServerStatus;
+use gpui::{TestAppContext, VisualTestContext, WindowHandle};
+use rpc::{proto::DevServerStatus, ErrorCode, ErrorExt};
 use serde_json::json;
+use workspace::{AppState, Workspace};
 
-use crate::tests::TestServer;
+use crate::tests::{following_tests::join_channel, TestServer};
+
+use super::TestClient;
 
 #[gpui::test]
 async fn test_dev_server(cx: &mut gpui::TestAppContext, cx2: &mut gpui::TestAppContext) {
     let (server, client) = TestServer::start1(cx).await;
 
-    let channel_id = server
-        .make_channel("test", None, (&client, cx), &mut [])
-        .await;
+    let store = cx.update(|cx| remote_projects::Store::global(cx).clone());
 
-    let resp = client
-        .channel_store()
+    let resp = store
         .update(cx, |store, cx| {
-            store.create_dev_server(channel_id, "server-1".to_string(), cx)
+            store.create_dev_server("server-1".to_string(), cx)
         })
         .await
         .unwrap();
 
-    client.channel_store().update(cx, |store, _| {
-        assert_eq!(store.dev_servers_for_id(channel_id).len(), 1);
-        assert_eq!(store.dev_servers_for_id(channel_id)[0].name, "server-1");
-        assert_eq!(
-            store.dev_servers_for_id(channel_id)[0].status,
-            DevServerStatus::Offline
-        );
+    store.update(cx, |store, _| {
+        assert_eq!(store.dev_servers().len(), 1);
+        assert_eq!(store.dev_servers()[0].name, "server-1");
+        assert_eq!(store.dev_servers()[0].status, DevServerStatus::Offline);
     });
 
     let dev_server = server.create_dev_server(resp.access_token, cx2).await;
     cx.executor().run_until_parked();
-    client.channel_store().update(cx, |store, _| {
-        assert_eq!(
-            store.dev_servers_for_id(channel_id)[0].status,
-            DevServerStatus::Online
-        );
+    store.update(cx, |store, _| {
+        assert_eq!(store.dev_servers()[0].status, DevServerStatus::Online);
     });
 
     dev_server
@@ -54,13 +49,10 @@ async fn test_dev_server(cx: &mut gpui::TestAppContext, cx2: &mut gpui::TestAppC
         )
         .await;
 
-    client
-        .channel_store()
+    store
         .update(cx, |store, cx| {
             store.create_remote_project(
-                channel_id,
                 client::DevServerId(resp.dev_server_id),
-                "project-1".to_string(),
                 "/remote".to_string(),
                 cx,
             )
@@ -70,12 +62,11 @@ async fn test_dev_server(cx: &mut gpui::TestAppContext, cx2: &mut gpui::TestAppC
 
     cx.executor().run_until_parked();
 
-    let remote_workspace = client
-        .channel_store()
+    let remote_workspace = store
         .update(cx, |store, cx| {
-            let projects = store.remote_projects_for_id(channel_id);
+            let projects = store.remote_projects();
             assert_eq!(projects.len(), 1);
-            assert_eq!(projects[0].name, "project-1");
+            assert_eq!(projects[0].path, "/remote");
             workspace::join_remote_project(
                 projects[0].project_id.unwrap(),
                 client.app_state.clone(),
@@ -87,19 +78,19 @@ async fn test_dev_server(cx: &mut gpui::TestAppContext, cx2: &mut gpui::TestAppC
 
     cx.executor().run_until_parked();
 
-    let cx2 = VisualTestContext::from_window(remote_workspace.into(), cx).as_mut();
-    cx2.simulate_keystrokes("cmd-p 1 enter");
+    let cx = VisualTestContext::from_window(remote_workspace.into(), cx).as_mut();
+    cx.simulate_keystrokes("cmd-p 1 enter");
 
     let editor = remote_workspace
-        .update(cx2, |ws, cx| {
+        .update(cx, |ws, cx| {
             ws.active_item_as::<Editor>(cx).unwrap().clone()
         })
         .unwrap();
-    editor.update(cx2, |ed, cx| {
+    editor.update(cx, |ed, cx| {
         assert_eq!(ed.text(cx).to_string(), "remote\nremote\nremote");
     });
-    cx2.simulate_input("wow!");
-    cx2.simulate_keystrokes("cmd-s");
+    cx.simulate_input("wow!");
+    cx.simulate_keystrokes("cmd-s");
 
     let content = dev_server
         .fs()
@@ -108,3 +99,263 @@ async fn test_dev_server(cx: &mut gpui::TestAppContext, cx2: &mut gpui::TestAppC
         .unwrap();
     assert_eq!(content, "wow!remote\nremote\nremote\n");
 }
+
+#[gpui::test]
+async fn test_dev_server_env_files(
+    cx1: &mut gpui::TestAppContext,
+    cx2: &mut gpui::TestAppContext,
+    cx3: &mut gpui::TestAppContext,
+) {
+    let (server, client1, client2, channel_id) = TestServer::start2(cx1, cx2).await;
+
+    let (_dev_server, remote_workspace) =
+        create_remote_project(&server, client1.app_state.clone(), cx1, cx3).await;
+
+    cx1.executor().run_until_parked();
+
+    let cx1 = VisualTestContext::from_window(remote_workspace.into(), cx1).as_mut();
+    cx1.simulate_keystrokes("cmd-p . e enter");
+
+    let editor = remote_workspace
+        .update(cx1, |ws, cx| {
+            ws.active_item_as::<Editor>(cx).unwrap().clone()
+        })
+        .unwrap();
+    editor.update(cx1, |ed, cx| {
+        assert_eq!(ed.text(cx).to_string(), "SECRET");
+    });
+
+    cx1.update(|cx| {
+        workspace::join_channel(
+            channel_id,
+            client1.app_state.clone(),
+            Some(remote_workspace),
+            cx,
+        )
+    })
+    .await
+    .unwrap();
+    cx1.executor().run_until_parked();
+
+    remote_workspace
+        .update(cx1, |ws, cx| {
+            assert!(ws.project().read(cx).is_shared());
+        })
+        .unwrap();
+
+    join_channel(channel_id, &client2, cx2).await.unwrap();
+    cx2.executor().run_until_parked();
+
+    let (workspace2, cx2) = client2.active_workspace(cx2);
+    let editor = workspace2.update(cx2, |ws, cx| {
+        ws.active_item_as::<Editor>(cx).unwrap().clone()
+    });
+    // TODO: it'd be nice to hide .env files from other people
+    editor.update(cx2, |ed, cx| {
+        assert_eq!(ed.text(cx).to_string(), "SECRET");
+    });
+}
+
+async fn create_remote_project(
+    server: &TestServer,
+    client_app_state: Arc<AppState>,
+    cx: &mut TestAppContext,
+    cx_devserver: &mut TestAppContext,
+) -> (TestClient, WindowHandle<Workspace>) {
+    let store = cx.update(|cx| remote_projects::Store::global(cx).clone());
+
+    let resp = store
+        .update(cx, |store, cx| {
+            store.create_dev_server("server-1".to_string(), cx)
+        })
+        .await
+        .unwrap();
+    let dev_server = server
+        .create_dev_server(resp.access_token, cx_devserver)
+        .await;
+
+    cx.executor().run_until_parked();
+
+    dev_server
+        .fs()
+        .insert_tree(
+            "/remote",
+            json!({
+                "1.txt": "remote\nremote\nremote",
+                ".env": "SECRET",
+            }),
+        )
+        .await;
+
+    store
+        .update(cx, |store, cx| {
+            store.create_remote_project(
+                client::DevServerId(resp.dev_server_id),
+                "/remote".to_string(),
+                cx,
+            )
+        })
+        .await
+        .unwrap();
+
+    cx.executor().run_until_parked();
+
+    let workspace = store
+        .update(cx, |store, cx| {
+            let projects = store.remote_projects();
+            assert_eq!(projects.len(), 1);
+            assert_eq!(projects[0].path, "/remote");
+            workspace::join_remote_project(projects[0].project_id.unwrap(), client_app_state, cx)
+        })
+        .await
+        .unwrap();
+
+    cx.executor().run_until_parked();
+
+    (dev_server, workspace)
+}
+
+#[gpui::test]
+async fn test_dev_server_leave_room(
+    cx1: &mut gpui::TestAppContext,
+    cx2: &mut gpui::TestAppContext,
+    cx3: &mut gpui::TestAppContext,
+) {
+    let (server, client1, client2, channel_id) = TestServer::start2(cx1, cx2).await;
+
+    let (_dev_server, remote_workspace) =
+        create_remote_project(&server, client1.app_state.clone(), cx1, cx3).await;
+
+    cx1.update(|cx| {
+        workspace::join_channel(
+            channel_id,
+            client1.app_state.clone(),
+            Some(remote_workspace),
+            cx,
+        )
+    })
+    .await
+    .unwrap();
+    cx1.executor().run_until_parked();
+
+    remote_workspace
+        .update(cx1, |ws, cx| {
+            assert!(ws.project().read(cx).is_shared());
+        })
+        .unwrap();
+
+    join_channel(channel_id, &client2, cx2).await.unwrap();
+    cx2.executor().run_until_parked();
+
+    cx1.update(|cx| ActiveCall::global(cx).update(cx, |active_call, cx| active_call.hang_up(cx)))
+        .await
+        .unwrap();
+
+    cx1.executor().run_until_parked();
+
+    let (workspace, cx2) = client2.active_workspace(cx2);
+    cx2.update(|cx| assert!(workspace.read(cx).project().read(cx).is_disconnected()));
+}
+
+#[gpui::test]
+async fn test_dev_server_reconnect(
+    cx1: &mut gpui::TestAppContext,
+    cx2: &mut gpui::TestAppContext,
+    cx3: &mut gpui::TestAppContext,
+) {
+    let (mut server, client1) = TestServer::start1(cx1).await;
+    let channel_id = server
+        .make_channel("test", None, (&client1, cx1), &mut [])
+        .await;
+
+    let (_dev_server, remote_workspace) =
+        create_remote_project(&server, client1.app_state.clone(), cx1, cx3).await;
+
+    cx1.update(|cx| {
+        workspace::join_channel(
+            channel_id,
+            client1.app_state.clone(),
+            Some(remote_workspace),
+            cx,
+        )
+    })
+    .await
+    .unwrap();
+    cx1.executor().run_until_parked();
+
+    remote_workspace
+        .update(cx1, |ws, cx| {
+            assert!(ws.project().read(cx).is_shared());
+        })
+        .unwrap();
+
+    drop(client1);
+
+    let client2 = server.create_client(cx2, "user_a").await;
+
+    let store = cx2.update(|cx| remote_projects::Store::global(cx).clone());
+
+    store
+        .update(cx2, |store, cx| {
+            let projects = store.remote_projects();
+            workspace::join_remote_project(
+                projects[0].project_id.unwrap(),
+                client2.app_state.clone(),
+                cx,
+            )
+        })
+        .await
+        .unwrap();
+}
+
+#[gpui::test]
+async fn test_create_remote_project_path_validation(
+    cx1: &mut gpui::TestAppContext,
+    cx2: &mut gpui::TestAppContext,
+    cx3: &mut gpui::TestAppContext,
+) {
+    let (server, client1) = TestServer::start1(cx1).await;
+    let _channel_id = server
+        .make_channel("test", None, (&client1, cx1), &mut [])
+        .await;
+
+    // Creating a project with a path that does exist should not fail
+    let (_dev_server, _) =
+        create_remote_project(&server, client1.app_state.clone(), cx1, cx2).await;
+
+    cx1.executor().run_until_parked();
+
+    let store = cx1.update(|cx| remote_projects::Store::global(cx).clone());
+
+    let resp = store
+        .update(cx1, |store, cx| {
+            store.create_dev_server("server-2".to_string(), cx)
+        })
+        .await
+        .unwrap();
+
+    cx1.executor().run_until_parked();
+
+    let _dev_server = server.create_dev_server(resp.access_token, cx3).await;
+
+    cx1.executor().run_until_parked();
+
+    // Creating a remote project with a path that does not exist should fail
+    let result = store
+        .update(cx1, |store, cx| {
+            store.create_remote_project(
+                client::DevServerId(resp.dev_server_id),
+                "/notfound".to_string(),
+                cx,
+            )
+        })
+        .await;
+
+    cx1.executor().run_until_parked();
+
+    let error = result.unwrap_err();
+    assert!(matches!(
+        error.error_code(),
+        ErrorCode::RemoteProjectPathDoesNotExist
+    ));
+}

crates/collab/src/tests/integration_tests.rs 🔗

@@ -3743,6 +3743,10 @@ async fn test_leaving_project(
 
     buffer_b2.read_with(cx_b, |buffer, _| assert_eq!(buffer.text(), "a-contents"));
 
+    project_a.read_with(cx_a, |project, _| {
+        assert_eq!(project.collaborators().len(), 2);
+    });
+
     // Drop client B's connection and ensure client A and client C observe client B leaving.
     client_b.disconnect(&cx_b.to_async());
     executor.advance_clock(RECONNECT_TIMEOUT);

crates/collab/src/tests/test_server.rs 🔗

@@ -284,6 +284,7 @@ impl TestServer {
             collab_ui::init(&app_state, cx);
             file_finder::init(cx);
             menu::init();
+            remote_projects::init(client.clone(), cx);
             settings::KeymapFile::load_asset("keymaps/default-macos.json", cx).unwrap();
         });
 

crates/collab_ui/Cargo.toml 🔗

@@ -39,7 +39,6 @@ db.workspace = true
 editor.workspace = true
 emojis.workspace = true
 extensions_ui.workspace = true
-feature_flags.workspace = true
 futures.workspace = true
 fuzzy.workspace = true
 gpui.workspace = true

crates/collab_ui/src/channel_view.rs 🔗

@@ -305,10 +305,6 @@ impl ChannelView {
                 });
             }
             ChannelBufferEvent::BufferEdited => {
-                // Emit the edited event on the editor context so that other views can update it's state (e.g. markdown preview)
-                self.editor.update(cx, |_, cx| {
-                    cx.emit(EditorEvent::Edited);
-                });
                 if self.editor.read(cx).is_focused(cx) {
                     self.acknowledge_buffer_version(cx);
                 } else {

crates/collab_ui/src/collab_panel.rs 🔗

@@ -1,20 +1,17 @@
 mod channel_modal;
 mod contact_finder;
-mod dev_server_modal;
 
 use self::channel_modal::ChannelModal;
-use self::dev_server_modal::DevServerModal;
 use crate::{
     channel_view::ChannelView, chat_panel::ChatPanel, face_pile::FacePile,
     CollaborationPanelSettings,
 };
 use call::ActiveCall;
-use channel::{Channel, ChannelEvent, ChannelStore, RemoteProject};
+use channel::{Channel, ChannelEvent, ChannelStore};
 use client::{ChannelId, Client, Contact, ProjectId, User, UserStore};
 use contact_finder::ContactFinder;
 use db::kvp::KEY_VALUE_STORE;
 use editor::{Editor, EditorElement, EditorStyle};
-use feature_flags::{self, FeatureFlagAppExt};
 use fuzzy::{match_strings, StringMatchCandidate};
 use gpui::{
     actions, anchored, canvas, deferred, div, fill, list, point, prelude::*, px, AnyElement,
@@ -27,7 +24,7 @@ use gpui::{
 use menu::{Cancel, Confirm, SecondaryConfirm, SelectNext, SelectPrev};
 use project::{Fs, Project};
 use rpc::{
-    proto::{self, ChannelVisibility, DevServerStatus, PeerId},
+    proto::{self, ChannelVisibility, PeerId},
     ErrorCode, ErrorExt,
 };
 use serde_derive::{Deserialize, Serialize};
@@ -191,7 +188,6 @@ enum ListEntry {
         id: ProjectId,
         name: SharedString,
     },
-    RemoteProject(channel::RemoteProject),
     Contact {
         contact: Arc<Contact>,
         calling: bool,
@@ -282,23 +278,10 @@ impl CollabPanel {
                 .push(cx.observe(&this.user_store, |this, _, cx| {
                     this.update_entries(true, cx)
                 }));
-            let mut has_opened = false;
-            this.subscriptions.push(cx.observe(
-                &this.channel_store,
-                move |this, channel_store, cx| {
-                    if !has_opened {
-                        if !channel_store
-                            .read(cx)
-                            .dev_servers_for_id(ChannelId(1))
-                            .is_empty()
-                        {
-                            this.manage_remote_projects(ChannelId(1), cx);
-                            has_opened = true;
-                        }
-                    }
+            this.subscriptions
+                .push(cx.observe(&this.channel_store, move |this, _, cx| {
                     this.update_entries(true, cx)
-                },
-            ));
+                }));
             this.subscriptions
                 .push(cx.observe(&active_call, |this, _, cx| this.update_entries(true, cx)));
             this.subscriptions.push(cx.subscribe(
@@ -586,7 +569,6 @@ impl CollabPanel {
                 }
 
                 let hosted_projects = channel_store.projects_for_id(channel.id);
-                let remote_projects = channel_store.remote_projects_for_id(channel.id);
                 let has_children = channel_store
                     .channel_at_index(mat.candidate_id + 1)
                     .map_or(false, |next_channel| {
@@ -624,12 +606,6 @@ impl CollabPanel {
                 for (name, id) in hosted_projects {
                     self.entries.push(ListEntry::HostedProject { id, name });
                 }
-
-                if cx.has_flag::<feature_flags::Remoting>() {
-                    for remote_project in remote_projects {
-                        self.entries.push(ListEntry::RemoteProject(remote_project));
-                    }
-                }
             }
         }
 
@@ -1089,59 +1065,6 @@ impl CollabPanel {
         .tooltip(move |cx| Tooltip::text("Open Project", cx))
     }
 
-    fn render_remote_project(
-        &self,
-        remote_project: &RemoteProject,
-        is_selected: bool,
-        cx: &mut ViewContext<Self>,
-    ) -> impl IntoElement {
-        let id = remote_project.id;
-        let name = remote_project.name.clone();
-        let maybe_project_id = remote_project.project_id;
-
-        let dev_server = self
-            .channel_store
-            .read(cx)
-            .find_dev_server_by_id(remote_project.dev_server_id);
-
-        let tooltip_text = SharedString::from(match dev_server {
-            Some(dev_server) => format!("Open Remote Project ({})", dev_server.name),
-            None => "Open Remote Project".to_string(),
-        });
-
-        let dev_server_is_online = dev_server.map(|s| s.status) == Some(DevServerStatus::Online);
-
-        let dev_server_text_color = if dev_server_is_online {
-            Color::Default
-        } else {
-            Color::Disabled
-        };
-
-        ListItem::new(ElementId::NamedInteger(
-            "remote-project".into(),
-            id.0 as usize,
-        ))
-        .indent_level(2)
-        .indent_step_size(px(20.))
-        .selected(is_selected)
-        .on_click(cx.listener(move |this, _, cx| {
-            //TODO display error message if dev server is offline
-            if dev_server_is_online {
-                if let Some(project_id) = maybe_project_id {
-                    this.join_remote_project(project_id, cx);
-                }
-            }
-        }))
-        .start_slot(
-            h_flex()
-                .relative()
-                .gap_1()
-                .child(IconButton::new(0, IconName::FileTree).icon_color(dev_server_text_color)),
-        )
-        .child(Label::new(name.clone()).color(dev_server_text_color))
-        .tooltip(move |cx| Tooltip::text(tooltip_text.clone(), cx))
-    }
-
     fn has_subchannels(&self, ix: usize) -> bool {
         self.entries.get(ix).map_or(false, |entry| {
             if let ListEntry::Channel { has_children, .. } = entry {
@@ -1343,24 +1266,11 @@ impl CollabPanel {
                 }
 
                 if self.channel_store.read(cx).is_root_channel(channel_id) {
-                    context_menu = context_menu
-                        .separator()
-                        .entry(
-                            "Manage Members",
-                            None,
-                            cx.handler_for(&this, move |this, cx| {
-                                this.manage_members(channel_id, cx)
-                            }),
-                        )
-                        .when(cx.has_flag::<feature_flags::Remoting>(), |context_menu| {
-                            context_menu.entry(
-                                "Manage Remote Projects",
-                                None,
-                                cx.handler_for(&this, move |this, cx| {
-                                    this.manage_remote_projects(channel_id, cx)
-                                }),
-                            )
-                        })
+                    context_menu = context_menu.separator().entry(
+                        "Manage Members",
+                        None,
+                        cx.handler_for(&this, move |this, cx| this.manage_members(channel_id, cx)),
+                    )
                 } else {
                     context_menu = context_menu.entry(
                         "Move this channel",
@@ -1624,12 +1534,6 @@ impl CollabPanel {
                     } => {
                         // todo()
                     }
-                    ListEntry::RemoteProject(project) => {
-                        if let Some(project_id) = project.project_id {
-                            self.join_remote_project(project_id, cx)
-                        }
-                    }
-
                     ListEntry::OutgoingRequest(_) => {}
                     ListEntry::ChannelEditor { .. } => {}
                 }
@@ -1801,18 +1705,6 @@ impl CollabPanel {
         self.show_channel_modal(channel_id, channel_modal::Mode::ManageMembers, cx);
     }
 
-    fn manage_remote_projects(&mut self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
-        let channel_store = self.channel_store.clone();
-        let Some(workspace) = self.workspace.upgrade() else {
-            return;
-        };
-        workspace.update(cx, |workspace, cx| {
-            workspace.toggle_modal(cx, |cx| {
-                DevServerModal::new(channel_store.clone(), channel_id, cx)
-            });
-        });
-    }
-
     fn remove_selected_channel(&mut self, _: &Remove, cx: &mut ViewContext<Self>) {
         if let Some(channel) = self.selected_channel() {
             self.remove_channel(channel.id, cx)
@@ -2113,18 +2005,6 @@ impl CollabPanel {
         .detach_and_prompt_err("Failed to join channel", cx, |_, _| None)
     }
 
-    fn join_remote_project(&mut self, project_id: ProjectId, cx: &mut ViewContext<Self>) {
-        let Some(workspace) = self.workspace.upgrade() else {
-            return;
-        };
-        let app_state = workspace.read(cx).app_state().clone();
-        workspace::join_remote_project(project_id, app_state, cx).detach_and_prompt_err(
-            "Failed to join project",
-            cx,
-            |_, _| None,
-        )
-    }
-
     fn join_channel_chat(&mut self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
         let Some(workspace) = self.workspace.upgrade() else {
             return;
@@ -2260,9 +2140,6 @@ impl CollabPanel {
             ListEntry::HostedProject { id, name } => self
                 .render_channel_project(*id, name, is_selected, cx)
                 .into_any_element(),
-            ListEntry::RemoteProject(remote_project) => self
-                .render_remote_project(remote_project, is_selected, cx)
-                .into_any_element(),
         }
     }
 
@@ -3005,11 +2882,6 @@ impl PartialEq for ListEntry {
                     return id == other_id;
                 }
             }
-            ListEntry::RemoteProject(project) => {
-                if let ListEntry::RemoteProject(other) = other {
-                    return project.id == other.id;
-                }
-            }
             ListEntry::ChannelNotes { channel_id } => {
                 if let ListEntry::ChannelNotes {
                     channel_id: other_id,

crates/collab_ui/src/collab_panel/dev_server_modal.rs 🔗

@@ -1,622 +0,0 @@
-use channel::{ChannelStore, DevServer, RemoteProject};
-use client::{ChannelId, DevServerId, RemoteProjectId};
-use editor::Editor;
-use gpui::{
-    AppContext, ClipboardItem, DismissEvent, EventEmitter, FocusHandle, FocusableView, Model,
-    ScrollHandle, Task, View, ViewContext,
-};
-use rpc::proto::{self, CreateDevServerResponse, DevServerStatus};
-use ui::{prelude::*, Indicator, List, ListHeader, ModalContent, ModalHeader, Tooltip};
-use util::ResultExt;
-use workspace::ModalView;
-
-pub struct DevServerModal {
-    mode: Mode,
-    focus_handle: FocusHandle,
-    scroll_handle: ScrollHandle,
-    channel_store: Model<ChannelStore>,
-    channel_id: ChannelId,
-    remote_project_name_editor: View<Editor>,
-    remote_project_path_editor: View<Editor>,
-    dev_server_name_editor: View<Editor>,
-    _subscriptions: [gpui::Subscription; 2],
-}
-
-#[derive(Default)]
-struct CreateDevServer {
-    creating: Option<Task<()>>,
-    dev_server: Option<CreateDevServerResponse>,
-}
-
-struct CreateRemoteProject {
-    dev_server_id: DevServerId,
-    creating: Option<Task<()>>,
-    remote_project: Option<proto::RemoteProject>,
-}
-
-enum Mode {
-    Default,
-    CreateRemoteProject(CreateRemoteProject),
-    CreateDevServer(CreateDevServer),
-}
-
-impl DevServerModal {
-    pub fn new(
-        channel_store: Model<ChannelStore>,
-        channel_id: ChannelId,
-        cx: &mut ViewContext<Self>,
-    ) -> Self {
-        let name_editor = cx.new_view(|cx| Editor::single_line(cx));
-        let path_editor = cx.new_view(|cx| Editor::single_line(cx));
-        let dev_server_name_editor = cx.new_view(|cx| {
-            let mut editor = Editor::single_line(cx);
-            editor.set_placeholder_text("Dev server name", cx);
-            editor
-        });
-
-        let focus_handle = cx.focus_handle();
-
-        let subscriptions = [
-            cx.observe(&channel_store, |_, _, cx| {
-                cx.notify();
-            }),
-            cx.on_focus_out(&focus_handle, |_, _cx| { /* cx.emit(DismissEvent) */ }),
-        ];
-
-        Self {
-            mode: Mode::Default,
-            focus_handle,
-            scroll_handle: ScrollHandle::new(),
-            channel_store,
-            channel_id,
-            remote_project_name_editor: name_editor,
-            remote_project_path_editor: path_editor,
-            dev_server_name_editor,
-            _subscriptions: subscriptions,
-        }
-    }
-
-    pub fn create_remote_project(
-        &mut self,
-        dev_server_id: DevServerId,
-        cx: &mut ViewContext<Self>,
-    ) {
-        let channel_id = self.channel_id;
-        let name = self
-            .remote_project_name_editor
-            .read(cx)
-            .text(cx)
-            .trim()
-            .to_string();
-        let path = self
-            .remote_project_path_editor
-            .read(cx)
-            .text(cx)
-            .trim()
-            .to_string();
-
-        if name == "" {
-            return;
-        }
-        if path == "" {
-            return;
-        }
-
-        let create = self.channel_store.update(cx, |store, cx| {
-            store.create_remote_project(channel_id, dev_server_id, name, path, cx)
-        });
-
-        let task = cx.spawn(|this, mut cx| async move {
-            let result = create.await;
-            if let Err(e) = &result {
-                cx.prompt(
-                    gpui::PromptLevel::Critical,
-                    "Failed to create project",
-                    Some(&format!("{:?}. Please try again.", e)),
-                    &["Ok"],
-                )
-                .await
-                .log_err();
-            }
-            this.update(&mut cx, |this, _| {
-                this.mode = Mode::CreateRemoteProject(CreateRemoteProject {
-                    dev_server_id,
-                    creating: None,
-                    remote_project: result.ok().and_then(|r| r.remote_project),
-                });
-            })
-            .log_err();
-        });
-
-        self.mode = Mode::CreateRemoteProject(CreateRemoteProject {
-            dev_server_id,
-            creating: Some(task),
-            remote_project: None,
-        });
-    }
-
-    pub fn create_dev_server(&mut self, cx: &mut ViewContext<Self>) {
-        let name = self
-            .dev_server_name_editor
-            .read(cx)
-            .text(cx)
-            .trim()
-            .to_string();
-
-        if name == "" {
-            return;
-        }
-
-        let dev_server = self.channel_store.update(cx, |store, cx| {
-            store.create_dev_server(self.channel_id, name.clone(), cx)
-        });
-
-        let task = cx.spawn(|this, mut cx| async move {
-            match dev_server.await {
-                Ok(dev_server) => {
-                    this.update(&mut cx, |this, _| {
-                        this.mode = Mode::CreateDevServer(CreateDevServer {
-                            creating: None,
-                            dev_server: Some(dev_server),
-                        });
-                    })
-                    .log_err();
-                }
-                Err(e) => {
-                    cx.prompt(
-                        gpui::PromptLevel::Critical,
-                        "Failed to create server",
-                        Some(&format!("{:?}. Please try again.", e)),
-                        &["Ok"],
-                    )
-                    .await
-                    .log_err();
-                    this.update(&mut cx, |this, _| {
-                        this.mode = Mode::CreateDevServer(Default::default());
-                    })
-                    .log_err();
-                }
-            }
-        });
-
-        self.mode = Mode::CreateDevServer(CreateDevServer {
-            creating: Some(task),
-            dev_server: None,
-        });
-        cx.notify()
-    }
-
-    fn cancel(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
-        match self.mode {
-            Mode::Default => cx.emit(DismissEvent),
-            Mode::CreateRemoteProject(_) | Mode::CreateDevServer(_) => {
-                self.mode = Mode::Default;
-                cx.notify();
-            }
-        }
-    }
-
-    fn render_dev_server(
-        &mut self,
-        dev_server: &DevServer,
-        cx: &mut ViewContext<Self>,
-    ) -> impl IntoElement {
-        let channel_store = self.channel_store.read(cx);
-        let dev_server_id = dev_server.id;
-        let status = dev_server.status;
-
-        v_flex()
-            .w_full()
-            .child(
-                h_flex()
-                    .group("dev-server")
-                    .justify_between()
-                    .child(
-                        h_flex()
-                            .gap_2()
-                            .child(
-                                div()
-                                    .id(("status", dev_server.id.0))
-                                    .relative()
-                                    .child(Icon::new(IconName::Server).size(IconSize::Small))
-                                    .child(
-                                        div().absolute().bottom_0().left(rems_from_px(8.0)).child(
-                                            Indicator::dot().color(match status {
-                                                DevServerStatus::Online => Color::Created,
-                                                DevServerStatus::Offline => Color::Deleted,
-                                            }),
-                                        ),
-                                    )
-                                    .tooltip(move |cx| {
-                                        Tooltip::text(
-                                            match status {
-                                                DevServerStatus::Online => "Online",
-                                                DevServerStatus::Offline => "Offline",
-                                            },
-                                            cx,
-                                        )
-                                    }),
-                            )
-                            .child(dev_server.name.clone())
-                            .child(
-                                h_flex()
-                                    .visible_on_hover("dev-server")
-                                    .gap_1()
-                                    .child(
-                                        IconButton::new("edit-dev-server", IconName::Pencil)
-                                            .disabled(true) //TODO implement this on the collab side
-                                            .tooltip(|cx| {
-                                                Tooltip::text("Coming Soon - Edit dev server", cx)
-                                            }),
-                                    )
-                                    .child(
-                                        IconButton::new("remove-dev-server", IconName::Trash)
-                                            .disabled(true) //TODO implement this on the collab side
-                                            .tooltip(|cx| {
-                                                Tooltip::text("Coming Soon - Remove dev server", cx)
-                                            }),
-                                    ),
-                            ),
-                    )
-                    .child(
-                        h_flex().gap_1().child(
-                            IconButton::new("add-remote-project", IconName::Plus)
-                                .tooltip(|cx| Tooltip::text("Add a remote project", cx))
-                                .on_click(cx.listener(move |this, _, cx| {
-                                    this.mode = Mode::CreateRemoteProject(CreateRemoteProject {
-                                        dev_server_id,
-                                        creating: None,
-                                        remote_project: None,
-                                    });
-                                    cx.notify();
-                                })),
-                        ),
-                    ),
-            )
-            .child(
-                v_flex()
-                    .w_full()
-                    .bg(cx.theme().colors().title_bar_background)
-                    .border()
-                    .border_color(cx.theme().colors().border_variant)
-                    .rounded_md()
-                    .my_1()
-                    .py_0p5()
-                    .px_3()
-                    .child(
-                        List::new().empty_message("No projects.").children(
-                            channel_store
-                                .remote_projects_for_id(dev_server.channel_id)
-                                .iter()
-                                .filter_map(|remote_project| {
-                                    if remote_project.dev_server_id == dev_server.id {
-                                        Some(self.render_remote_project(remote_project, cx))
-                                    } else {
-                                        None
-                                    }
-                                }),
-                        ),
-                    ),
-            )
-        // .child(div().ml_8().child(
-        //     Button::new(("add-project", dev_server_id.0), "Add Project").on_click(cx.listener(
-        //         move |this, _, cx| {
-        //             this.mode = Mode::CreateRemoteProject(CreateRemoteProject {
-        //                 dev_server_id,
-        //                 creating: None,
-        //                 remote_project: None,
-        //             });
-        //             cx.notify();
-        //         },
-        //     )),
-        // ))
-    }
-
-    fn render_remote_project(
-        &mut self,
-        project: &RemoteProject,
-        _: &mut ViewContext<Self>,
-    ) -> impl IntoElement {
-        h_flex()
-            .gap_2()
-            .child(Icon::new(IconName::FileTree))
-            .child(Label::new(project.name.clone()))
-            .child(Label::new(format!("({})", project.path.clone())).color(Color::Muted))
-    }
-
-    fn render_create_dev_server(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
-        let Mode::CreateDevServer(CreateDevServer {
-            creating,
-            dev_server,
-        }) = &self.mode
-        else {
-            unreachable!()
-        };
-
-        self.dev_server_name_editor.update(cx, |editor, _| {
-            editor.set_read_only(creating.is_some() || dev_server.is_some())
-        });
-        v_flex()
-            .px_1()
-            .pt_0p5()
-            .gap_px()
-            .child(
-                v_flex().py_0p5().px_1().child(
-                    h_flex()
-                        .px_1()
-                        .py_0p5()
-                        .child(
-                            IconButton::new("back", IconName::ArrowLeft)
-                                .style(ButtonStyle::Transparent)
-                                .on_click(cx.listener(|this, _: &gpui::ClickEvent, cx| {
-                                    this.mode = Mode::Default;
-                                    cx.notify();
-                                })),
-                        )
-                        .child(Headline::new("Register dev server")),
-                ),
-            )
-            .child(
-                h_flex()
-                    .ml_5()
-                    .gap_2()
-                    .child("Name")
-                    .child(self.dev_server_name_editor.clone())
-                    .on_action(
-                        cx.listener(|this, _: &menu::Confirm, cx| this.create_dev_server(cx)),
-                    )
-                    .when(creating.is_none() && dev_server.is_none(), |div| {
-                        div.child(
-                            Button::new("create-dev-server", "Create").on_click(cx.listener(
-                                move |this, _, cx| {
-                                    this.create_dev_server(cx);
-                                },
-                            )),
-                        )
-                    })
-                    .when(creating.is_some() && dev_server.is_none(), |div| {
-                        div.child(Button::new("create-dev-server", "Creating...").disabled(true))
-                    }),
-            )
-            .when_some(dev_server.clone(), |div, dev_server| {
-                let channel_store = self.channel_store.read(cx);
-                let status = channel_store
-                    .find_dev_server_by_id(DevServerId(dev_server.dev_server_id))
-                    .map(|server| server.status)
-                    .unwrap_or(DevServerStatus::Offline);
-                let instructions = SharedString::from(format!(
-                    "zed --dev-server-token {}",
-                    dev_server.access_token
-                ));
-                div.child(
-                    v_flex()
-                        .ml_8()
-                        .gap_2()
-                        .child(Label::new(format!(
-                            "Please log into `{}` and run:",
-                            dev_server.name
-                        )))
-                        .child(instructions.clone())
-                        .child(
-                            IconButton::new("copy-access-token", IconName::Copy)
-                                .on_click(cx.listener(move |_, _, cx| {
-                                    cx.write_to_clipboard(ClipboardItem::new(
-                                        instructions.to_string(),
-                                    ))
-                                }))
-                                .icon_size(IconSize::Small)
-                                .tooltip(|cx| Tooltip::text("Copy access token", cx)),
-                        )
-                        .when(status == DevServerStatus::Offline, |this| {
-                            this.child(Label::new("Waiting for connection..."))
-                        })
-                        .when(status == DevServerStatus::Online, |this| {
-                            this.child(Label::new("Connection established! 🎊")).child(
-                                Button::new("done", "Done").on_click(cx.listener(|this, _, cx| {
-                                    this.mode = Mode::Default;
-                                    cx.notify();
-                                })),
-                            )
-                        }),
-                )
-            })
-    }
-
-    fn render_default(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
-        let channel_store = self.channel_store.read(cx);
-        let dev_servers = channel_store.dev_servers_for_id(self.channel_id);
-        // let dev_servers = Vec::new();
-
-        v_flex()
-            .id("scroll-container")
-            .h_full()
-            .overflow_y_scroll()
-            .track_scroll(&self.scroll_handle)
-            .px_1()
-            .pt_0p5()
-            .gap_px()
-            .child(
-                ModalHeader::new("Manage Remote Project")
-                    .child(Headline::new("Remote Projects").size(HeadlineSize::Small)),
-            )
-            .child(
-                ModalContent::new().child(
-                    List::new()
-                        .empty_message("No dev servers registered.")
-                        .header(Some(
-                            ListHeader::new("Dev Servers").end_slot(
-                                Button::new("register-dev-server-button", "New Server")
-                                    .icon(IconName::Plus)
-                                    .icon_position(IconPosition::Start)
-                                    .tooltip(|cx| Tooltip::text("Register a new dev server", cx))
-                                    .on_click(cx.listener(|this, _, cx| {
-                                        this.mode = Mode::CreateDevServer(Default::default());
-                                        this.dev_server_name_editor
-                                            .read(cx)
-                                            .focus_handle(cx)
-                                            .focus(cx);
-                                        cx.notify();
-                                    })),
-                            ),
-                        ))
-                        .children(dev_servers.iter().map(|dev_server| {
-                            self.render_dev_server(dev_server, cx).into_any_element()
-                        })),
-                ),
-            )
-    }
-
-    fn render_create_project(&self, cx: &mut ViewContext<Self>) -> impl IntoElement {
-        let Mode::CreateRemoteProject(CreateRemoteProject {
-            dev_server_id,
-            creating,
-            remote_project,
-        }) = &self.mode
-        else {
-            unreachable!()
-        };
-        let channel_store = self.channel_store.read(cx);
-        let (dev_server_name, dev_server_status) = channel_store
-            .find_dev_server_by_id(*dev_server_id)
-            .map(|server| (server.name.clone(), server.status))
-            .unwrap_or((SharedString::from(""), DevServerStatus::Offline));
-        v_flex()
-            .px_1()
-            .pt_0p5()
-            .gap_px()
-            .child(
-                ModalHeader::new("Manage Remote Project")
-                    .child(Headline::new("Manage Remote Projects")),
-            )
-            .child(
-                h_flex()
-                    .py_0p5()
-                    .px_1()
-                    .child(div().px_1().py_0p5().child(
-                        IconButton::new("back", IconName::ArrowLeft).on_click(cx.listener(
-                            |this, _, cx| {
-                                this.mode = Mode::Default;
-                                cx.notify()
-                            },
-                        )),
-                    ))
-                    .child("Add Project..."),
-            )
-            .child(
-                h_flex()
-                    .ml_5()
-                    .gap_2()
-                    .child(
-                        div()
-                            .id(("status", dev_server_id.0))
-                            .relative()
-                            .child(Icon::new(IconName::Server))
-                            .child(div().absolute().bottom_0().left(rems_from_px(12.0)).child(
-                                Indicator::dot().color(match dev_server_status {
-                                    DevServerStatus::Online => Color::Created,
-                                    DevServerStatus::Offline => Color::Deleted,
-                                }),
-                            ))
-                            .tooltip(move |cx| {
-                                Tooltip::text(
-                                    match dev_server_status {
-                                        DevServerStatus::Online => "Online",
-                                        DevServerStatus::Offline => "Offline",
-                                    },
-                                    cx,
-                                )
-                            }),
-                    )
-                    .child(dev_server_name.clone()),
-            )
-            .child(
-                h_flex()
-                    .ml_5()
-                    .gap_2()
-                    .child("Name")
-                    .child(self.remote_project_name_editor.clone())
-                    .on_action(cx.listener(|this, _: &menu::Confirm, cx| {
-                        cx.focus_view(&this.remote_project_path_editor)
-                    })),
-            )
-            .child(
-                h_flex()
-                    .ml_5()
-                    .gap_2()
-                    .child("Path")
-                    .child(self.remote_project_path_editor.clone())
-                    .on_action(
-                        cx.listener(|this, _: &menu::Confirm, cx| this.create_dev_server(cx)),
-                    )
-                    .when(creating.is_none() && remote_project.is_none(), |div| {
-                        div.child(Button::new("create-remote-server", "Create").on_click({
-                            let dev_server_id = *dev_server_id;
-                            cx.listener(move |this, _, cx| {
-                                this.create_remote_project(dev_server_id, cx)
-                            })
-                        }))
-                    })
-                    .when(creating.is_some(), |div| {
-                        div.child(Button::new("create-dev-server", "Creating...").disabled(true))
-                    }),
-            )
-            .when_some(remote_project.clone(), |div, remote_project| {
-                let channel_store = self.channel_store.read(cx);
-                let status = channel_store
-                    .find_remote_project_by_id(RemoteProjectId(remote_project.id))
-                    .map(|project| {
-                        if project.project_id.is_some() {
-                            DevServerStatus::Online
-                        } else {
-                            DevServerStatus::Offline
-                        }
-                    })
-                    .unwrap_or(DevServerStatus::Offline);
-                div.child(
-                    v_flex()
-                        .ml_5()
-                        .ml_8()
-                        .gap_2()
-                        .when(status == DevServerStatus::Offline, |this| {
-                            this.child(Label::new("Waiting for project..."))
-                        })
-                        .when(status == DevServerStatus::Online, |this| {
-                            this.child(Label::new("Project online! 🎊")).child(
-                                Button::new("done", "Done").on_click(cx.listener(|this, _, cx| {
-                                    this.mode = Mode::Default;
-                                    cx.notify();
-                                })),
-                            )
-                        }),
-                )
-            })
-    }
-}
-impl ModalView for DevServerModal {}
-
-impl FocusableView for DevServerModal {
-    fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
-        self.focus_handle.clone()
-    }
-}
-
-impl EventEmitter<DismissEvent> for DevServerModal {}
-
-impl Render for DevServerModal {
-    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
-        div()
-            .track_focus(&self.focus_handle)
-            .elevation_3(cx)
-            .key_context("DevServerModal")
-            .on_action(cx.listener(Self::cancel))
-            .pb_4()
-            .w(rems(34.))
-            .min_h(rems(20.))
-            .max_h(rems(40.))
-            .child(match &self.mode {
-                Mode::Default => self.render_default(cx).into_any_element(),
-                Mode::CreateRemoteProject(_) => self.render_create_project(cx).into_any_element(),
-                Mode::CreateDevServer(_) => self.render_create_dev_server(cx).into_any_element(),
-            })
-    }
-}

crates/collab_ui/src/collab_titlebar_item.rs 🔗

@@ -171,44 +171,48 @@ impl Render for CollabTitlebarItem {
                         let room = room.read(cx);
                         let project = self.project.read(cx);
                         let is_local = project.is_local();
-                        let is_shared = is_local && project.is_shared();
+                        let is_remote_project = project.remote_project_id().is_some();
+                        let is_shared = (is_local || is_remote_project) && project.is_shared();
                         let is_muted = room.is_muted();
                         let is_deafened = room.is_deafened().unwrap_or(false);
                         let is_screen_sharing = room.is_screen_sharing();
                         let can_use_microphone = room.can_use_microphone();
                         let can_share_projects = room.can_share_projects();
 
-                        this.when(is_local && can_share_projects, |this| {
-                            this.child(
-                                Button::new(
-                                    "toggle_sharing",
-                                    if is_shared { "Unshare" } else { "Share" },
-                                )
-                                .tooltip(move |cx| {
-                                    Tooltip::text(
-                                        if is_shared {
-                                            "Stop sharing project with call participants"
-                                        } else {
-                                            "Share project with call participants"
-                                        },
-                                        cx,
+                        this.when(
+                            (is_local || is_remote_project) && can_share_projects,
+                            |this| {
+                                this.child(
+                                    Button::new(
+                                        "toggle_sharing",
+                                        if is_shared { "Unshare" } else { "Share" },
                                     )
-                                })
-                                .style(ButtonStyle::Subtle)
-                                .selected_style(ButtonStyle::Tinted(TintColor::Accent))
-                                .selected(is_shared)
-                                .label_size(LabelSize::Small)
-                                .on_click(cx.listener(
-                                    move |this, _, cx| {
-                                        if is_shared {
-                                            this.unshare_project(&Default::default(), cx);
-                                        } else {
-                                            this.share_project(&Default::default(), cx);
-                                        }
-                                    },
-                                )),
-                            )
-                        })
+                                    .tooltip(move |cx| {
+                                        Tooltip::text(
+                                            if is_shared {
+                                                "Stop sharing project with call participants"
+                                            } else {
+                                                "Share project with call participants"
+                                            },
+                                            cx,
+                                        )
+                                    })
+                                    .style(ButtonStyle::Subtle)
+                                    .selected_style(ButtonStyle::Tinted(TintColor::Accent))
+                                    .selected(is_shared)
+                                    .label_size(LabelSize::Small)
+                                    .on_click(cx.listener(
+                                        move |this, _, cx| {
+                                            if is_shared {
+                                                this.unshare_project(&Default::default(), cx);
+                                            } else {
+                                                this.share_project(&Default::default(), cx);
+                                            }
+                                        },
+                                    )),
+                                )
+                            },
+                        )
                         .child(
                             div()
                                 .child(
@@ -406,7 +410,7 @@ impl CollabTitlebarItem {
         )
     }
 
-    pub fn render_project_name(&self, cx: &mut ViewContext<Self>) -> impl Element {
+    pub fn render_project_name(&self, cx: &mut ViewContext<Self>) -> impl IntoElement {
         let name = {
             let mut names = self.project.read(cx).visible_worktrees(cx).map(|worktree| {
                 let worktree = worktree.read(cx);
@@ -423,15 +427,26 @@ impl CollabTitlebarItem {
         };
 
         let workspace = self.workspace.clone();
-        popover_menu("project_name_trigger")
-            .trigger(
-                Button::new("project_name_trigger", name)
-                    .when(!is_project_selected, |b| b.color(Color::Muted))
-                    .style(ButtonStyle::Subtle)
-                    .label_size(LabelSize::Small)
-                    .tooltip(move |cx| Tooltip::text("Recent Projects", cx)),
-            )
-            .menu(move |cx| Some(Self::render_project_popover(workspace.clone(), cx)))
+        Button::new("project_name_trigger", name)
+            .when(!is_project_selected, |b| b.color(Color::Muted))
+            .style(ButtonStyle::Subtle)
+            .label_size(LabelSize::Small)
+            .tooltip(move |cx| {
+                Tooltip::for_action(
+                    "Recent Projects",
+                    &recent_projects::OpenRecent {
+                        create_new_window: false,
+                    },
+                    cx,
+                )
+            })
+            .on_click(cx.listener(move |_, _, cx| {
+                if let Some(workspace) = workspace.upgrade() {
+                    workspace.update(cx, |workspace, cx| {
+                        RecentProjects::open(workspace, false, cx);
+                    })
+                }
+            }))
     }
 
     pub fn render_project_branch(&self, cx: &mut ViewContext<Self>) -> Option<impl Element> {
@@ -607,17 +622,6 @@ impl CollabTitlebarItem {
         Some(view)
     }
 
-    pub fn render_project_popover(
-        workspace: WeakView<Workspace>,
-        cx: &mut WindowContext<'_>,
-    ) -> View<RecentProjects> {
-        let view = RecentProjects::open_popover(workspace, cx);
-
-        let focus_handle = view.focus_handle(cx);
-        cx.focus(&focus_handle);
-        view
-    }
-
     fn render_connection_status(
         &self,
         status: &client::Status,

crates/editor/src/items.rs 🔗

@@ -81,6 +81,7 @@ impl FollowableItem for Editor {
             let mut buffers = futures::future::try_join_all(buffers?)
                 .await
                 .debug_assert_ok("leaders don't share views for unshared buffers")?;
+
             let editor = pane.update(&mut cx, |pane, cx| {
                 let mut editors = pane.items_of_type::<Self>();
                 editors.find(|editor| {

crates/headless/src/headless.rs 🔗

@@ -1,20 +1,25 @@
 use anyhow::Result;
-use client::{user::UserStore, Client, ClientSettings, RemoteProjectId};
+use client::RemoteProjectId;
+use client::{user::UserStore, Client, ClientSettings};
 use fs::Fs;
 use futures::Future;
-use gpui::{AppContext, AsyncAppContext, Context, Global, Model, ModelContext, Task, WeakModel};
+use gpui::{
+    AppContext, AsyncAppContext, BorrowAppContext, Context, Global, Model, ModelContext, Task,
+    WeakModel,
+};
 use language::LanguageRegistry;
 use node_runtime::NodeRuntime;
 use postage::stream::Stream;
-use project::Project;
-use rpc::{proto, TypedEnvelope};
-use settings::Settings;
+use project::{Project, WorktreeSettings};
+use rpc::{proto, ErrorCode, TypedEnvelope};
+use settings::{Settings, SettingsStore};
 use std::{collections::HashMap, sync::Arc};
 use util::{ResultExt, TryFutureExt};
 
 pub struct DevServer {
     client: Arc<Client>,
     app_state: AppState,
+    remote_shutdown: bool,
     projects: HashMap<RemoteProjectId, Model<Project>>,
     _subscriptions: Vec<client::Subscription>,
     _maintain_connection: Task<Option<()>>,
@@ -35,6 +40,15 @@ pub fn init(client: Arc<Client>, app_state: AppState, cx: &mut AppContext) {
     let dev_server = cx.new_model(|cx| DevServer::new(client.clone(), app_state, cx));
     cx.set_global(GlobalDevServer(dev_server.clone()));
 
+    // Dev server cannot have any private files for now
+    cx.update_global(|store: &mut SettingsStore, _| {
+        let old_settings = store.get::<WorktreeSettings>(None);
+        store.override_global(WorktreeSettings {
+            private_files: Some(vec![]),
+            ..old_settings.clone()
+        });
+    });
+
     // Set up a handler when the dev server is shut down by the user pressing Ctrl-C
     let (tx, rx) = futures::channel::oneshot::channel();
     set_ctrlc_handler(move || tx.send(()).log_err().unwrap()).log_err();
@@ -53,7 +67,7 @@ pub fn init(client: Arc<Client>, app_state: AppState, cx: &mut AppContext) {
                 log::info!("Connected to {}", server_url);
             }
             Err(e) => {
-                log::error!("Error connecting to {}: {}", server_url, e);
+                log::error!("Error connecting to '{}': {}", server_url, e);
                 cx.update(|cx| cx.quit()).log_err();
             }
         }
@@ -89,19 +103,31 @@ impl DevServer {
 
         DevServer {
             _subscriptions: vec![
-                client.add_message_handler(cx.weak_model(), Self::handle_dev_server_instructions)
+                client.add_message_handler(cx.weak_model(), Self::handle_dev_server_instructions),
+                client.add_request_handler(
+                    cx.weak_model(),
+                    Self::handle_validate_remote_project_request,
+                ),
+                client.add_message_handler(cx.weak_model(), Self::handle_shutdown),
             ],
             _maintain_connection: maintain_connection,
             projects: Default::default(),
+            remote_shutdown: false,
             app_state,
             client,
         }
     }
 
     fn app_will_quit(&mut self, _: &mut ModelContext<Self>) -> impl Future<Output = ()> {
-        let request = self.client.request(proto::ShutdownDevServer {});
+        let request = if self.remote_shutdown {
+            None
+        } else {
+            Some(self.client.request(proto::ShutdownDevServer {}))
+        };
         async move {
-            request.await.log_err();
+            if let Some(request) = request {
+                request.await.log_err();
+            }
         }
     }
 
@@ -148,6 +174,35 @@ impl DevServer {
         Ok(())
     }
 
+    async fn handle_validate_remote_project_request(
+        this: Model<Self>,
+        envelope: TypedEnvelope<proto::ValidateRemoteProjectRequest>,
+        _: Arc<Client>,
+        cx: AsyncAppContext,
+    ) -> Result<proto::Ack> {
+        let path = std::path::Path::new(&envelope.payload.path);
+        let fs = cx.read_model(&this, |this, _| this.app_state.fs.clone())?;
+
+        let path_exists = fs.is_dir(path).await;
+        if !path_exists {
+            return Err(anyhow::anyhow!(ErrorCode::RemoteProjectPathDoesNotExist))?;
+        }
+
+        Ok(proto::Ack {})
+    }
+
+    async fn handle_shutdown(
+        this: Model<Self>,
+        _envelope: TypedEnvelope<proto::ShutdownDevServer>,
+        _: Arc<Client>,
+        mut cx: AsyncAppContext,
+    ) -> Result<()> {
+        this.update(&mut cx, |this, cx| {
+            this.remote_shutdown = true;
+            cx.quit();
+        })
+    }
+
     fn unshare_project(
         &mut self,
         remote_project_id: &RemoteProjectId,

crates/picker/src/highlighted_match_with_paths.rs 🔗

@@ -11,6 +11,7 @@ pub struct HighlightedText {
     pub text: String,
     pub highlight_positions: Vec<usize>,
     pub char_count: usize,
+    pub color: Color,
 }
 
 impl HighlightedText {
@@ -39,13 +40,17 @@ impl HighlightedText {
             text,
             highlight_positions,
             char_count,
+            color: Color::Default,
         }
     }
-}
 
+    pub fn color(self, color: Color) -> Self {
+        Self { color, ..self }
+    }
+}
 impl RenderOnce for HighlightedText {
-    fn render(self, _cx: &mut WindowContext) -> impl IntoElement {
-        HighlightedLabel::new(self.text, self.highlight_positions)
+    fn render(self, _: &mut WindowContext) -> impl IntoElement {
+        HighlightedLabel::new(self.text, self.highlight_positions).color(self.color)
     }
 }
 

crates/project/src/project.rs 🔗

@@ -15,7 +15,8 @@ pub mod search_history;
 use anyhow::{anyhow, bail, Context as _, Result};
 use async_trait::async_trait;
 use client::{
-    proto, Client, Collaborator, PendingEntitySubscription, ProjectId, TypedEnvelope, UserStore,
+    proto, Client, Collaborator, PendingEntitySubscription, ProjectId, RemoteProjectId,
+    TypedEnvelope, UserStore,
 };
 use clock::ReplicaId;
 use collections::{hash_map, BTreeMap, HashMap, HashSet, VecDeque};
@@ -207,6 +208,7 @@ pub struct Project {
     prettier_instances: HashMap<PathBuf, PrettierInstance>,
     tasks: Model<Inventory>,
     hosted_project_id: Option<ProjectId>,
+    remote_project_id: Option<client::RemoteProjectId>,
     search_history: SearchHistory,
 }
 
@@ -268,6 +270,7 @@ enum ProjectClientState {
         capability: Capability,
         remote_id: u64,
         replica_id: ReplicaId,
+        in_room: bool,
     },
 }
 
@@ -723,6 +726,7 @@ impl Project {
                 prettier_instances: HashMap::default(),
                 tasks,
                 hosted_project_id: None,
+                remote_project_id: None,
                 search_history: Self::new_search_history(),
             }
         })
@@ -836,6 +840,7 @@ impl Project {
                     capability: Capability::ReadWrite,
                     remote_id,
                     replica_id,
+                    in_room: response.payload.remote_project_id.is_none(),
                 },
                 supplementary_language_servers: HashMap::default(),
                 language_servers: Default::default(),
@@ -877,6 +882,10 @@ impl Project {
                 prettier_instances: HashMap::default(),
                 tasks,
                 hosted_project_id: None,
+                remote_project_id: response
+                    .payload
+                    .remote_project_id
+                    .map(|remote_project_id| RemoteProjectId(remote_project_id)),
                 search_history: Self::new_search_history(),
             };
             this.set_role(role, cx);
@@ -1235,6 +1244,10 @@ impl Project {
         self.hosted_project_id
     }
 
+    pub fn remote_project_id(&self) -> Option<RemoteProjectId> {
+        self.remote_project_id
+    }
+
     pub fn replica_id(&self) -> ReplicaId {
         match self.client_state {
             ProjectClientState::Remote { replica_id, .. } => replica_id,
@@ -1552,7 +1565,16 @@ impl Project {
 
     pub fn shared(&mut self, project_id: u64, cx: &mut ModelContext<Self>) -> Result<()> {
         if !matches!(self.client_state, ProjectClientState::Local) {
-            return Err(anyhow!("project was already shared"));
+            if let ProjectClientState::Remote { in_room, .. } = &mut self.client_state {
+                if *in_room || self.remote_project_id.is_none() {
+                    return Err(anyhow!("project was already shared"));
+                } else {
+                    *in_room = true;
+                    return Ok(());
+                }
+            } else {
+                return Err(anyhow!("project was already shared"));
+            }
         }
         self.client_subscriptions.push(
             self.client
@@ -1763,7 +1785,14 @@ impl Project {
 
     fn unshare_internal(&mut self, cx: &mut AppContext) -> Result<()> {
         if self.is_remote() {
-            return Err(anyhow!("attempted to unshare a remote project"));
+            if self.remote_project_id().is_some() {
+                if let ProjectClientState::Remote { in_room, .. } = &mut self.client_state {
+                    *in_room = false
+                }
+                return Ok(());
+            } else {
+                return Err(anyhow!("attempted to unshare a remote project"));
+            }
         }
 
         if let ProjectClientState::Shared { remote_id, .. } = self.client_state {
@@ -6959,7 +6988,8 @@ impl Project {
     pub fn is_shared(&self) -> bool {
         match &self.client_state {
             ProjectClientState::Shared { .. } => true,
-            ProjectClientState::Local | ProjectClientState::Remote { .. } => false,
+            ProjectClientState::Local => false,
+            ProjectClientState::Remote { in_room, .. } => *in_room,
         }
     }
 

crates/recent_projects/Cargo.toml 🔗

@@ -13,14 +13,21 @@ path = "src/recent_projects.rs"
 doctest = false
 
 [dependencies]
+anyhow.workspace = true
+feature_flags.workspace = true
 fuzzy.workspace = true
 gpui.workspace = true
 menu.workspace = true
 ordered-float.workspace = true
 picker.workspace = true
+remote_projects.workspace = true
+rpc.workspace = true
 serde.workspace = true
+settings.workspace = true
 smol.workspace = true
+theme.workspace = true
 ui.workspace = true
+ui_text_field.workspace = true
 util.workspace = true
 workspace.workspace = true
 

crates/recent_projects/src/recent_projects.rs 🔗

@@ -1,6 +1,9 @@
+mod remote_projects;
+
+use feature_flags::FeatureFlagAppExt;
 use fuzzy::{StringMatch, StringMatchCandidate};
 use gpui::{
-    AnyElement, AppContext, DismissEvent, EventEmitter, FocusHandle, FocusableView, Result,
+    Action, AnyElement, AppContext, DismissEvent, EventEmitter, FocusHandle, FocusableView,
     Subscription, Task, View, ViewContext, WeakView,
 };
 use ordered_float::OrderedFloat;
@@ -8,11 +11,21 @@ use picker::{
     highlighted_match_with_paths::{HighlightedMatchWithPaths, HighlightedText},
     Picker, PickerDelegate,
 };
+use remote_projects::RemoteProjects;
+use rpc::proto::DevServerStatus;
 use serde::Deserialize;
-use std::{path::Path, sync::Arc};
-use ui::{prelude::*, tooltip_container, ListItem, ListItemSpacing, Tooltip};
-use util::paths::PathExt;
-use workspace::{ModalView, Workspace, WorkspaceId, WorkspaceLocation, WORKSPACE_DB};
+use std::{
+    path::{Path, PathBuf},
+    sync::Arc,
+};
+use ui::{
+    prelude::*, tooltip_container, ButtonLike, IconWithIndicator, Indicator, KeyBinding, ListItem,
+    ListItemSpacing, Tooltip,
+};
+use util::{paths::PathExt, ResultExt};
+use workspace::{
+    AppState, ModalView, SerializedWorkspaceLocation, Workspace, WorkspaceId, WORKSPACE_DB,
+};
 
 #[derive(PartialEq, Clone, Deserialize, Default)]
 pub struct OpenRecent {
@@ -25,9 +38,12 @@ fn default_create_new_window() -> bool {
 }
 
 gpui::impl_actions!(projects, [OpenRecent]);
+gpui::actions!(projects, [OpenRemote]);
 
 pub fn init(cx: &mut AppContext) {
     cx.observe_new_views(RecentProjects::register).detach();
+    cx.observe_new_views(remote_projects::RemoteProjects::register)
+        .detach();
 }
 
 pub struct RecentProjects {
@@ -55,10 +71,11 @@ impl RecentProjects {
             let workspaces = WORKSPACE_DB
                 .recent_workspaces_on_disk()
                 .await
+                .log_err()
                 .unwrap_or_default();
             this.update(&mut cx, move |this, cx| {
                 this.picker.update(cx, move |picker, cx| {
-                    picker.delegate.workspaces = workspaces;
+                    picker.delegate.set_workspaces(workspaces);
                     picker.update_matches(picker.query(cx), cx)
                 })
             })
@@ -75,9 +92,7 @@ impl RecentProjects {
     fn register(workspace: &mut Workspace, _: &mut ViewContext<Workspace>) {
         workspace.register_action(|workspace, open_recent: &OpenRecent, cx| {
             let Some(recent_projects) = workspace.active_modal::<Self>(cx) else {
-                if let Some(handler) = Self::open(workspace, open_recent.create_new_window, cx) {
-                    handler.detach_and_log_err(cx);
-                }
+                Self::open(workspace, open_recent.create_new_window, cx);
                 return;
             };
 
@@ -89,24 +104,17 @@ impl RecentProjects {
         });
     }
 
-    fn open(
-        _: &mut Workspace,
+    pub fn open(
+        workspace: &mut Workspace,
         create_new_window: bool,
         cx: &mut ViewContext<Workspace>,
-    ) -> Option<Task<Result<()>>> {
-        Some(cx.spawn(|workspace, mut cx| async move {
-            workspace.update(&mut cx, |workspace, cx| {
-                let weak_workspace = cx.view().downgrade();
-                workspace.toggle_modal(cx, |cx| {
-                    let delegate =
-                        RecentProjectsDelegate::new(weak_workspace, create_new_window, true);
-
-                    let modal = Self::new(delegate, 34., cx);
-                    modal
-                });
-            })?;
-            Ok(())
-        }))
+    ) {
+        let weak = cx.view().downgrade();
+        workspace.toggle_modal(cx, |cx| {
+            let delegate = RecentProjectsDelegate::new(weak, create_new_window, true);
+            let modal = Self::new(delegate, 34., cx);
+            modal
+        })
     }
 
     pub fn open_popover(workspace: WeakView<Workspace>, cx: &mut WindowContext<'_>) -> View<Self> {
@@ -143,13 +151,14 @@ impl Render for RecentProjects {
 
 pub struct RecentProjectsDelegate {
     workspace: WeakView<Workspace>,
-    workspaces: Vec<(WorkspaceId, WorkspaceLocation)>,
+    workspaces: Vec<(WorkspaceId, SerializedWorkspaceLocation)>,
     selected_match_index: usize,
     matches: Vec<StringMatch>,
     render_paths: bool,
     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_remote_projects: bool,
 }
 
 impl RecentProjectsDelegate {
@@ -162,8 +171,17 @@ impl RecentProjectsDelegate {
             create_new_window,
             render_paths,
             reset_selected_match_index: true,
+            has_any_remote_projects: false,
         }
     }
+
+    pub fn set_workspaces(&mut self, workspaces: Vec<(WorkspaceId, SerializedWorkspaceLocation)>) {
+        self.workspaces = workspaces;
+        self.has_any_remote_projects = self
+            .workspaces
+            .iter()
+            .any(|(_, location)| matches!(location, SerializedWorkspaceLocation::Remote(_)));
+    }
 }
 impl EventEmitter<DismissEvent> for RecentProjectsDelegate {}
 impl PickerDelegate for RecentProjectsDelegate {
@@ -210,12 +228,18 @@ impl PickerDelegate for RecentProjectsDelegate {
             .iter()
             .enumerate()
             .map(|(id, (_, location))| {
-                let combined_string = location
-                    .paths()
-                    .iter()
-                    .map(|path| path.compact().to_string_lossy().into_owned())
-                    .collect::<Vec<_>>()
-                    .join("");
+                let combined_string = match location {
+                    SerializedWorkspaceLocation::Local(paths) => paths
+                        .paths()
+                        .iter()
+                        .map(|path| path.compact().to_string_lossy().into_owned())
+                        .collect::<Vec<_>>()
+                        .join(""),
+                    SerializedWorkspaceLocation::Remote(remote_project) => {
+                        format!("{}{}", remote_project.dev_server_name, remote_project.path)
+                    }
+                };
+
                 StringMatchCandidate::new(id, combined_string)
             })
             .collect::<Vec<_>>();
@@ -261,30 +285,69 @@ impl PickerDelegate for RecentProjectsDelegate {
                     if workspace.database_id() == *candidate_workspace_id {
                         Task::ready(Ok(()))
                     } else {
-                        let candidate_paths = candidate_workspace_location.paths().as_ref().clone();
-                        if replace_current_window {
-                            cx.spawn(move |workspace, mut cx| async move {
-                                let continue_replacing = workspace
-                                    .update(&mut cx, |workspace, cx| {
-                                        workspace.prepare_to_close(true, cx)
-                                    })?
-                                    .await?;
-                                if continue_replacing {
-                                    workspace
-                                        .update(&mut cx, |workspace, cx| {
-                                            workspace.open_workspace_for_paths(
-                                                true,
-                                                candidate_paths,
-                                                cx,
-                                            )
-                                        })?
-                                        .await
+                        match candidate_workspace_location {
+                            SerializedWorkspaceLocation::Local(paths) => {
+                                let paths = paths.paths().as_ref().clone();
+                                if replace_current_window {
+                                    cx.spawn(move |workspace, mut cx| async move {
+                                        let continue_replacing = workspace
+                                            .update(&mut cx, |workspace, cx| {
+                                                workspace.prepare_to_close(true, cx)
+                                            })?
+                                            .await?;
+                                        if continue_replacing {
+                                            workspace
+                                                .update(&mut cx, |workspace, cx| {
+                                                    workspace
+                                                        .open_workspace_for_paths(true, paths, cx)
+                                                })?
+                                                .await
+                                        } else {
+                                            Ok(())
+                                        }
+                                    })
                                 } else {
-                                    Ok(())
+                                    workspace.open_workspace_for_paths(false, paths, cx)
                                 }
-                            })
-                        } else {
-                            workspace.open_workspace_for_paths(false, candidate_paths, cx)
+                            }
+                            //TODO support opening remote projects in the same window
+                            SerializedWorkspaceLocation::Remote(remote_project) => {
+                                let store = ::remote_projects::Store::global(cx).read(cx);
+                                let Some(project_id) = store
+                                    .remote_project(remote_project.id)
+                                    .and_then(|p| p.project_id)
+                                else {
+                                    let dev_server_name = remote_project.dev_server_name.clone();
+                                    return cx.spawn(|workspace, mut cx| async move {
+                                        let response =
+                                            cx.prompt(gpui::PromptLevel::Warning,
+                                                "Dev Server is offline",
+                                                Some(format!("Cannot connect to {}. To debug open the remote project settings.", dev_server_name).as_str()),
+                                                &["Ok", "Open Settings"]
+                                            ).await?;
+                                        if response == 1 {
+                                            workspace.update(&mut cx, |workspace, cx| {
+                                                workspace.toggle_modal(cx, |cx| RemoteProjects::new(cx))
+                                            })?;
+                                        } else {
+                                            workspace.update(&mut cx, |workspace, cx| {
+                                                RecentProjects::open(workspace, true, cx);
+                                            })?;
+                                        }
+                                        Ok(())
+                                    })
+                                };
+                                if let Some(app_state) = AppState::global(cx).upgrade() {
+                                    let task =
+                                        workspace::join_remote_project(project_id, app_state, cx);
+                                    cx.spawn(|_, _| async move {
+                                        task.await?;
+                                        Ok(())
+                                    })
+                                } else {
+                                    Task::ready(Err(anyhow::anyhow!("App state not found")))
+                                }
+                            }
                         }
                     }
                 })
@@ -295,6 +358,14 @@ impl PickerDelegate for RecentProjectsDelegate {
 
     fn dismissed(&mut self, _: &mut ViewContext<Picker<Self>>) {}
 
+    fn no_matches_text(&self, _cx: &mut WindowContext) -> SharedString {
+        if self.workspaces.is_empty() {
+            "Recently opened projects will show up here".into()
+        } else {
+            "No matches".into()
+        }
+    }
+
     fn render_match(
         &self,
         ix: usize,
@@ -308,9 +379,30 @@ impl PickerDelegate for RecentProjectsDelegate {
         let (workspace_id, location) = &self.workspaces[hit.candidate_id];
         let is_current_workspace = self.is_current_workspace(*workspace_id, cx);
 
+        let is_remote = matches!(location, SerializedWorkspaceLocation::Remote(_));
+        let dev_server_status =
+            if let SerializedWorkspaceLocation::Remote(remote_project) = location {
+                let store = ::remote_projects::Store::global(cx).read(cx);
+                Some(
+                    store
+                        .remote_project(remote_project.id)
+                        .and_then(|p| store.dev_server(p.dev_server_id))
+                        .map(|s| s.status)
+                        .unwrap_or_default(),
+                )
+            } else {
+                None
+            };
+
         let mut path_start_offset = 0;
-        let (match_labels, paths): (Vec<_>, Vec<_>) = location
-            .paths()
+        let paths = match location {
+            SerializedWorkspaceLocation::Local(paths) => paths.paths(),
+            SerializedWorkspaceLocation::Remote(remote_project) => Arc::new(vec![PathBuf::from(
+                format!("{}:{}", remote_project.dev_server_name, remote_project.path),
+            )]),
+        };
+
+        let (match_labels, paths): (Vec<_>, Vec<_>) = paths
             .iter()
             .map(|path| {
                 let path = path.compact();
@@ -323,22 +415,58 @@ impl PickerDelegate for RecentProjectsDelegate {
             .unzip();
 
         let highlighted_match = HighlightedMatchWithPaths {
-            match_label: HighlightedText::join(match_labels.into_iter().flatten(), ", "),
+            match_label: HighlightedText::join(match_labels.into_iter().flatten(), ", ").color(
+                if matches!(dev_server_status, Some(DevServerStatus::Offline)) {
+                    Color::Disabled
+                } else {
+                    Color::Default
+                },
+            ),
             paths,
         };
 
         Some(
             ListItem::new(ix)
+                .selected(selected)
                 .inset(true)
                 .spacing(ListItemSpacing::Sparse)
-                .selected(selected)
-                .child({
-                    let mut highlighted = highlighted_match.clone();
-                    if !self.render_paths {
-                        highlighted.paths.clear();
-                    }
-                    highlighted.render(cx)
-                })
+                .child(
+                    h_flex()
+                        .flex_grow()
+                        .gap_3()
+                        .when(self.has_any_remote_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)
+                                    .color(Color::Muted)
+                                    .into_any_element()
+                            })
+                        })
+                        .child({
+                            let mut highlighted = highlighted_match.clone();
+                            if !self.render_paths {
+                                highlighted.paths.clear();
+                            }
+                            highlighted.render(cx)
+                        }),
+                )
                 .when(!is_current_workspace, |el| {
                     let delete_button = div()
                         .child(
@@ -369,6 +497,39 @@ impl PickerDelegate for RecentProjectsDelegate {
                 }),
         )
     }
+
+    fn render_footer(&self, cx: &mut ViewContext<Picker<Self>>) -> Option<AnyElement> {
+        if !cx.has_flag::<feature_flags::Remoting>() {
+            return None;
+        }
+        Some(
+            h_flex()
+                .border_t_1()
+                .py_2()
+                .pr_2()
+                .border_color(cx.theme().colors().border)
+                .justify_end()
+                .gap_4()
+                .child(
+                    ButtonLike::new("remote")
+                        .when_some(KeyBinding::for_action(&OpenRemote, cx), |button, key| {
+                            button.child(key)
+                        })
+                        .child(Label::new("Connect remote…").color(Color::Muted))
+                        .on_click(|_, cx| cx.dispatch_action(OpenRemote.boxed_clone())),
+                )
+                .child(
+                    ButtonLike::new("local")
+                        .when_some(
+                            KeyBinding::for_action(&workspace::Open, cx),
+                            |button, key| button.child(key),
+                        )
+                        .child(Label::new("Open folder…").color(Color::Muted))
+                        .on_click(|_, cx| cx.dispatch_action(workspace::Open.boxed_clone())),
+                )
+                .into_any(),
+        )
+    }
 }
 
 // Compute the highlighted text for the name and path
@@ -406,6 +567,7 @@ fn highlights_for_path(
             text: text.to_string(),
             highlight_positions,
             char_count,
+            color: Color::Default,
         }
     });
 
@@ -415,6 +577,7 @@ fn highlights_for_path(
             text: path_string.to_string(),
             highlight_positions: path_positions,
             char_count: path_char_count,
+            color: Color::Default,
         },
     )
 }
@@ -430,7 +593,7 @@ impl RecentProjectsDelegate {
                     .await
                     .unwrap_or_default();
                 this.update(&mut cx, move |picker, cx| {
-                    picker.delegate.workspaces = workspaces;
+                    picker.delegate.set_workspaces(workspaces);
                     picker.delegate.set_selected_index(ix - 1, cx);
                     picker.delegate.reset_selected_match_index = false;
                     picker.update_matches(picker.query(cx), cx)
@@ -475,7 +638,7 @@ mod tests {
     use gpui::{TestAppContext, WindowHandle};
     use project::Project;
     use serde_json::json;
-    use workspace::{open_paths, AppState};
+    use workspace::{open_paths, AppState, LocalPaths};
 
     use super::*;
 
@@ -539,10 +702,10 @@ mod tests {
                         positions: Vec::new(),
                         string: "fake candidate".to_string(),
                     }];
-                    delegate.workspaces = vec![(
+                    delegate.set_workspaces(vec![(
                         WorkspaceId::default(),
-                        WorkspaceLocation::new(vec!["/test/path/"]),
-                    )];
+                        LocalPaths::new(vec!["/test/path/"]).into(),
+                    )]);
                 });
             })
             .unwrap();

crates/recent_projects/src/remote_projects.rs 🔗

@@ -0,0 +1,749 @@
+use std::time::Duration;
+
+use feature_flags::FeatureFlagViewExt;
+use gpui::{
+    percentage, Action, Animation, AnimationExt, AppContext, ClipboardItem, DismissEvent,
+    EventEmitter, FocusHandle, FocusableView, Model, ScrollHandle, Transformation, View,
+    ViewContext,
+};
+use remote_projects::{DevServer, DevServerId, RemoteProject, RemoteProjectId};
+use rpc::{
+    proto::{self, CreateDevServerResponse, DevServerStatus},
+    ErrorCode, ErrorExt,
+};
+use settings::Settings;
+use theme::ThemeSettings;
+use ui::{prelude::*, Indicator, List, ListHeader, ListItem, ModalContent, ModalHeader, Tooltip};
+use ui_text_field::{FieldLabelLayout, TextField};
+use util::ResultExt;
+use workspace::{notifications::DetachAndPromptErr, AppState, ModalView, Workspace};
+
+use crate::OpenRemote;
+
+pub struct RemoteProjects {
+    mode: Mode,
+    focus_handle: FocusHandle,
+    scroll_handle: ScrollHandle,
+    remote_project_store: Model<remote_projects::Store>,
+    remote_project_path_input: View<TextField>,
+    dev_server_name_input: View<TextField>,
+    _subscription: gpui::Subscription,
+}
+
+#[derive(Default)]
+struct CreateDevServer {
+    creating: bool,
+    dev_server: Option<CreateDevServerResponse>,
+}
+
+struct CreateRemoteProject {
+    dev_server_id: DevServerId,
+    creating: bool,
+    remote_project: Option<proto::RemoteProject>,
+}
+
+enum Mode {
+    Default,
+    CreateRemoteProject(CreateRemoteProject),
+    CreateDevServer(CreateDevServer),
+}
+
+impl RemoteProjects {
+    pub fn register(_: &mut Workspace, cx: &mut ViewContext<Workspace>) {
+        cx.observe_flag::<feature_flags::Remoting, _>(|enabled, workspace, _| {
+            if enabled {
+                workspace.register_action(|workspace, _: &OpenRemote, cx| {
+                    workspace.toggle_modal(cx, |cx| Self::new(cx))
+                });
+            }
+        })
+        .detach();
+    }
+
+    pub fn new(cx: &mut ViewContext<Self>) -> Self {
+        let remote_project_path_input = cx.new_view(|cx| TextField::new(cx, "", "Project path"));
+        let dev_server_name_input =
+            cx.new_view(|cx| TextField::new(cx, "Name", "").with_label(FieldLabelLayout::Stacked));
+
+        let focus_handle = cx.focus_handle();
+        let remote_project_store = remote_projects::Store::global(cx);
+
+        let subscription = cx.observe(&remote_project_store, |_, _, cx| {
+            cx.notify();
+        });
+
+        Self {
+            mode: Mode::Default,
+            focus_handle,
+            scroll_handle: ScrollHandle::new(),
+            remote_project_store,
+            remote_project_path_input,
+            dev_server_name_input,
+            _subscription: subscription,
+        }
+    }
+
+    pub fn create_remote_project(
+        &mut self,
+        dev_server_id: DevServerId,
+        cx: &mut ViewContext<Self>,
+    ) {
+        let path = self
+            .remote_project_path_input
+            .read(cx)
+            .editor()
+            .read(cx)
+            .text(cx)
+            .trim()
+            .to_string();
+
+        if path == "" {
+            return;
+        }
+
+        if self
+            .remote_project_store
+            .read(cx)
+            .remote_projects_for_server(dev_server_id)
+            .iter()
+            .any(|p| p.path == path)
+        {
+            cx.spawn(|_, mut cx| async move {
+                cx.prompt(
+                    gpui::PromptLevel::Critical,
+                    "Failed to create project",
+                    Some(&format!(
+                        "Project {} already exists for this dev server.",
+                        path
+                    )),
+                    &["Ok"],
+                )
+                .await
+            })
+            .detach_and_log_err(cx);
+            return;
+        }
+
+        let create = {
+            let path = path.clone();
+            self.remote_project_store.update(cx, |store, cx| {
+                store.create_remote_project(dev_server_id, path, cx)
+            })
+        };
+
+        cx.spawn(|this, mut cx| async move {
+            let result = create.await;
+            let remote_project = result.as_ref().ok().and_then(|r| r.remote_project.clone());
+            this.update(&mut cx, |this, _| {
+                this.mode = Mode::CreateRemoteProject(CreateRemoteProject {
+                    dev_server_id,
+                    creating: false,
+                    remote_project,
+                });
+            })
+            .log_err();
+            result
+        })
+        .detach_and_prompt_err("Failed to create project", cx, move |e, _| {
+            match e.error_code() {
+                ErrorCode::DevServerOffline => Some(
+                    "The dev server is offline. Please log in and check it is connected."
+                        .to_string(),
+                ),
+                ErrorCode::RemoteProjectPathDoesNotExist => {
+                    Some(format!("The path `{}` does not exist on the server.", path))
+                }
+                _ => None,
+            }
+        });
+
+        self.remote_project_path_input.update(cx, |input, cx| {
+            input.editor().update(cx, |editor, cx| {
+                editor.set_text("", cx);
+            });
+        });
+
+        self.mode = Mode::CreateRemoteProject(CreateRemoteProject {
+            dev_server_id,
+            creating: true,
+            remote_project: None,
+        });
+    }
+
+    pub fn create_dev_server(&mut self, cx: &mut ViewContext<Self>) {
+        let name = self
+            .dev_server_name_input
+            .read(cx)
+            .editor()
+            .read(cx)
+            .text(cx)
+            .trim()
+            .to_string();
+
+        if name == "" {
+            return;
+        }
+
+        let dev_server = self
+            .remote_project_store
+            .update(cx, |store, cx| store.create_dev_server(name.clone(), cx));
+
+        cx.spawn(|this, mut cx| async move {
+            let result = dev_server.await;
+
+            this.update(&mut cx, |this, _| match &result {
+                Ok(dev_server) => {
+                    this.mode = Mode::CreateDevServer(CreateDevServer {
+                        creating: false,
+                        dev_server: Some(dev_server.clone()),
+                    });
+                }
+                Err(_) => {
+                    this.mode = Mode::CreateDevServer(Default::default());
+                }
+            })
+            .log_err();
+            result
+        })
+        .detach_and_prompt_err("Failed to create server", cx, |_, _| None);
+
+        self.mode = Mode::CreateDevServer(CreateDevServer {
+            creating: true,
+            dev_server: None,
+        });
+        cx.notify()
+    }
+
+    fn delete_dev_server(&mut self, id: DevServerId, cx: &mut ViewContext<Self>) {
+        let answer = cx.prompt(
+            gpui::PromptLevel::Info,
+            "Are you sure?",
+            Some("This will delete the dev server and all of its remote projects."),
+            &["Delete", "Cancel"],
+        );
+
+        cx.spawn(|this, mut cx| async move {
+            let answer = answer.await?;
+
+            if answer != 0 {
+                return Ok(());
+            }
+
+            this.update(&mut cx, |this, cx| {
+                this.remote_project_store
+                    .update(cx, |store, cx| store.delete_dev_server(id, cx))
+            })?
+            .await
+        })
+        .detach_and_prompt_err("Failed to delete dev server", cx, |_, _| None);
+    }
+
+    fn confirm(&mut self, _: &menu::Confirm, cx: &mut ViewContext<Self>) {
+        match self.mode {
+            Mode::Default => {}
+            Mode::CreateRemoteProject(CreateRemoteProject { dev_server_id, .. }) => {
+                self.create_remote_project(dev_server_id, cx);
+            }
+            Mode::CreateDevServer(_) => {
+                self.create_dev_server(cx);
+            }
+        }
+    }
+
+    fn cancel(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
+        match self.mode {
+            Mode::Default => cx.emit(DismissEvent),
+            Mode::CreateRemoteProject(_) | Mode::CreateDevServer(_) => {
+                self.mode = Mode::Default;
+                self.focus_handle(cx).focus(cx);
+                cx.notify();
+            }
+        }
+    }
+
+    fn render_dev_server(
+        &mut self,
+        dev_server: &DevServer,
+        cx: &mut ViewContext<Self>,
+    ) -> impl IntoElement {
+        let dev_server_id = dev_server.id;
+        let status = dev_server.status;
+
+        v_flex()
+            .w_full()
+            .child(
+                h_flex()
+                    .group("dev-server")
+                    .justify_between()
+                    .child(
+                        h_flex()
+                            .gap_2()
+                            .child(
+                                div()
+                                    .id(("status", dev_server.id.0))
+                                    .relative()
+                                    .child(Icon::new(IconName::Server).size(IconSize::Small))
+                                    .child(
+                                        div().absolute().bottom_0().left(rems_from_px(8.0)).child(
+                                            Indicator::dot().color(match status {
+                                                DevServerStatus::Online => Color::Created,
+                                                DevServerStatus::Offline => Color::Hidden,
+                                            }),
+                                        ),
+                                    )
+                                    .tooltip(move |cx| {
+                                        Tooltip::text(
+                                            match status {
+                                                DevServerStatus::Online => "Online",
+                                                DevServerStatus::Offline => "Offline",
+                                            },
+                                            cx,
+                                        )
+                                    }),
+                            )
+                            .child(dev_server.name.clone())
+                            .child(
+                                h_flex()
+                                    .visible_on_hover("dev-server")
+                                    .gap_1()
+                                    .child(
+                                        IconButton::new("edit-dev-server", IconName::Pencil)
+                                            .disabled(true) //TODO implement this on the collab side
+                                            .tooltip(|cx| {
+                                                Tooltip::text("Coming Soon - Edit dev server", cx)
+                                            }),
+                                    )
+                                    .child({
+                                        let dev_server_id = dev_server.id;
+                                        IconButton::new("remove-dev-server", IconName::Trash)
+                                            .on_click(cx.listener(move |this, _, cx| {
+                                                this.delete_dev_server(dev_server_id, cx)
+                                            }))
+                                            .tooltip(|cx| Tooltip::text("Remove dev server", cx))
+                                    }),
+                            ),
+                    )
+                    .child(
+                        h_flex().gap_1().child(
+                            IconButton::new(
+                                ("add-remote-project", dev_server_id.0),
+                                IconName::Plus,
+                            )
+                            .tooltip(|cx| Tooltip::text("Add a remote project", cx))
+                            .on_click(cx.listener(
+                                move |this, _, cx| {
+                                    this.mode = Mode::CreateRemoteProject(CreateRemoteProject {
+                                        dev_server_id,
+                                        creating: false,
+                                        remote_project: None,
+                                    });
+                                    this.remote_project_path_input
+                                        .read(cx)
+                                        .focus_handle(cx)
+                                        .focus(cx);
+                                    cx.notify();
+                                },
+                            )),
+                        ),
+                    ),
+            )
+            .child(
+                v_flex()
+                    .w_full()
+                    .bg(cx.theme().colors().title_bar_background) // todo: this should be distinct
+                    .border()
+                    .border_color(cx.theme().colors().border_variant)
+                    .rounded_md()
+                    .my_1()
+                    .py_0p5()
+                    .px_3()
+                    .child(
+                        List::new().empty_message("No projects.").children(
+                            self.remote_project_store
+                                .read(cx)
+                                .remote_projects_for_server(dev_server.id)
+                                .iter()
+                                .map(|p| self.render_remote_project(p, cx)),
+                        ),
+                    ),
+            )
+    }
+
+    fn render_remote_project(
+        &mut self,
+        project: &RemoteProject,
+        cx: &mut ViewContext<Self>,
+    ) -> impl IntoElement {
+        let remote_project_id = project.id;
+        let project_id = project.project_id;
+        let is_online = project_id.is_some();
+
+        ListItem::new(("remote-project", remote_project_id.0))
+            .start_slot(Icon::new(IconName::FileTree).when(!is_online, |icon| icon.color(Color::Muted)))
+            .child(
+                    Label::new(project.path.clone())
+            )
+            .on_click(cx.listener(move |_, _, cx| {
+                if let Some(project_id) = project_id {
+                    if let Some(app_state) = AppState::global(cx).upgrade() {
+                        workspace::join_remote_project(project_id, app_state, cx)
+                            .detach_and_prompt_err("Could not join project", cx, |_, _| None)
+                    }
+                } else {
+                    cx.spawn(|_, mut cx| async move {
+                        cx.prompt(gpui::PromptLevel::Critical, "This project is offline", Some("The `zed` instance running on this dev server is not connected. You will have to restart it."), &["Ok"]).await.log_err();
+                    }).detach();
+                }
+            }))
+    }
+
+    fn render_create_dev_server(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
+        let Mode::CreateDevServer(CreateDevServer {
+            creating,
+            dev_server,
+        }) = &self.mode
+        else {
+            unreachable!()
+        };
+
+        self.dev_server_name_input.update(cx, |input, cx| {
+            input.set_disabled(*creating || dev_server.is_some(), cx);
+        });
+
+        v_flex()
+            .id("scroll-container")
+            .h_full()
+            .overflow_y_scroll()
+            .track_scroll(&self.scroll_handle)
+            .px_1()
+            .pt_0p5()
+            .gap_px()
+            .child(
+                ModalHeader::new("remote-projects")
+                    .show_back_button(true)
+                    .child(Headline::new("New dev server").size(HeadlineSize::Small)),
+            )
+            .child(
+                ModalContent::new().child(
+                    v_flex()
+                        .w_full()
+                        .child(
+                            h_flex()
+                                .pb_2()
+                                .items_end()
+                                .w_full()
+                                .px_2()
+                                .border_b_1()
+                                .border_color(cx.theme().colors().border)
+                                .child(
+                                    div()
+                                        .pl_2()
+                                        .max_w(rems(16.))
+                                        .child(self.dev_server_name_input.clone()),
+                                )
+                                .child(
+                                    div()
+                                        .pl_1()
+                                        .pb(px(3.))
+                                        .when(!*creating && dev_server.is_none(), |div| {
+                                            div.child(Button::new("create-dev-server", "Create").on_click(
+                                                cx.listener(move |this, _, cx| {
+                                                    this.create_dev_server(cx);
+                                                }),
+                                            ))
+                                        })
+                                        .when(*creating && dev_server.is_none(), |div| {
+                                            div.child(
+                                                Button::new("create-dev-server", "Creating...")
+                                                    .disabled(true),
+                                            )
+                                        }),
+                                )
+                        )
+                        .when(dev_server.is_none(), |div| {
+                            div.px_2().child(Label::new("Once you have created a dev server, you will be given a command to run on the server to register it.").color(Color::Muted))
+                        })
+                        .when_some(dev_server.clone(), |div, dev_server| {
+                            let status = self
+                                .remote_project_store
+                                .read(cx)
+                                .dev_server_status(DevServerId(dev_server.dev_server_id));
+
+                            let instructions = SharedString::from(format!(
+                                "zed --dev-server-token {}",
+                                dev_server.access_token
+                            ));
+                            div.child(
+                                v_flex()
+                                    .pl_2()
+                                    .pt_2()
+                                    .gap_2()
+                                    .child(
+                                        h_flex().justify_between().w_full()
+                                            .child(Label::new(format!(
+                                                    "Please log into `{}` and run:",
+                                                    dev_server.name
+                                            )))
+                                            .child(
+                                                Button::new("copy-access-token", "Copy Instructions")
+                                                    .icon(Some(IconName::Copy))
+                                                    .icon_size(IconSize::Small)
+                                                    .on_click({
+                                                        let instructions = instructions.clone();
+                                                        cx.listener(move |_, _, cx| {
+                                                        cx.write_to_clipboard(ClipboardItem::new(
+                                                            instructions.to_string(),
+                                                        ))
+                                                    })})
+                                            )
+                                    )
+                                    .child(
+                                        v_flex()
+                                        .w_full()
+                                        .bg(cx.theme().colors().title_bar_background) // todo: this should be distinct
+                                        .border()
+                                        .border_color(cx.theme().colors().border_variant)
+                                        .rounded_md()
+                                        .my_1()
+                                        .py_0p5()
+                                        .px_3()
+                                        .font(ThemeSettings::get_global(cx).buffer_font.family.clone())
+                                        .child(Label::new(instructions))
+                                    )
+                                    .when(status == DevServerStatus::Offline, |this| {
+                                        this.child(
+
+                                        h_flex()
+                                            .gap_2()
+                                            .child(
+                                                Icon::new(IconName::ArrowCircle)
+                                                    .size(IconSize::Medium)
+                                                    .with_animation(
+                                                        "arrow-circle",
+                                                        Animation::new(Duration::from_secs(2)).repeat(),
+                                                        |icon, delta| {
+                                                            icon.transform(Transformation::rotate(percentage(delta)))
+                                                        },
+                                                    ),
+                                            )
+                                            .child(
+                                                Label::new("Waiting for connection…"),
+                                            )
+                                        )
+                                    })
+                                    .when(status == DevServerStatus::Online, |this| {
+                                        this.child(Label::new("🎊 Connection established!"))
+                                            .child(
+                                                h_flex().justify_end().child(
+                                                    Button::new("done", "Done").on_click(cx.listener(
+                                                        |_, _, cx| {
+                                                            cx.dispatch_action(menu::Cancel.boxed_clone())
+                                                        },
+                                                    ))
+                                                ),
+                                            )
+                                    }),
+                            )
+                        }),
+                )
+            )
+    }
+
+    fn render_default(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
+        let dev_servers = self.remote_project_store.read(cx).dev_servers();
+
+        v_flex()
+            .id("scroll-container")
+            .h_full()
+            .overflow_y_scroll()
+            .track_scroll(&self.scroll_handle)
+            .px_1()
+            .pt_0p5()
+            .gap_px()
+            .child(
+                ModalHeader::new("remote-projects")
+                    .show_dismiss_button(true)
+                    .child(Headline::new("Remote Projects").size(HeadlineSize::Small)),
+            )
+            .child(
+                ModalContent::new().child(
+                    List::new()
+                        .empty_message("No dev servers registered.")
+                        .header(Some(
+                            ListHeader::new("Dev Servers").end_slot(
+                                Button::new("register-dev-server-button", "New Server")
+                                    .icon(IconName::Plus)
+                                    .icon_position(IconPosition::Start)
+                                    .tooltip(|cx| Tooltip::text("Register a new dev server", cx))
+                                    .on_click(cx.listener(|this, _, cx| {
+                                        this.mode = Mode::CreateDevServer(Default::default());
+
+                                        this.dev_server_name_input.update(cx, |input, cx| {
+                                            input.editor().update(cx, |editor, cx| {
+                                                editor.set_text("", cx);
+                                            });
+                                            input.focus_handle(cx).focus(cx)
+                                        });
+
+                                        cx.notify();
+                                    })),
+                            ),
+                        ))
+                        .children(dev_servers.iter().map(|dev_server| {
+                            self.render_dev_server(dev_server, cx).into_any_element()
+                        })),
+                ),
+            )
+    }
+
+    fn render_create_remote_project(&self, cx: &mut ViewContext<Self>) -> impl IntoElement {
+        let Mode::CreateRemoteProject(CreateRemoteProject {
+            dev_server_id,
+            creating,
+            remote_project,
+        }) = &self.mode
+        else {
+            unreachable!()
+        };
+
+        let dev_server = self
+            .remote_project_store
+            .read(cx)
+            .dev_server(*dev_server_id)
+            .cloned();
+
+        let (dev_server_name, dev_server_status) = dev_server
+            .map(|server| (server.name, server.status))
+            .unwrap_or((SharedString::from(""), DevServerStatus::Offline));
+
+        v_flex()
+            .px_1()
+            .pt_0p5()
+            .gap_px()
+            .child(
+                v_flex().py_0p5().px_1().child(
+                    h_flex()
+                        .px_1()
+                        .py_0p5()
+                        .child(
+                            IconButton::new("back", IconName::ArrowLeft)
+                                .style(ButtonStyle::Transparent)
+                                .on_click(cx.listener(|_, _: &gpui::ClickEvent, cx| {
+                                    cx.dispatch_action(menu::Cancel.boxed_clone())
+                                })),
+                        )
+                        .child(Headline::new("Add remote project").size(HeadlineSize::Small)),
+                ),
+            )
+            .child(
+                h_flex()
+                    .ml_5()
+                    .gap_2()
+                    .child(
+                        div()
+                            .id(("status", dev_server_id.0))
+                            .relative()
+                            .child(Icon::new(IconName::Server))
+                            .child(div().absolute().bottom_0().left(rems_from_px(12.0)).child(
+                                Indicator::dot().color(match dev_server_status {
+                                    DevServerStatus::Online => Color::Created,
+                                    DevServerStatus::Offline => Color::Hidden,
+                                }),
+                            ))
+                            .tooltip(move |cx| {
+                                Tooltip::text(
+                                    match dev_server_status {
+                                        DevServerStatus::Online => "Online",
+                                        DevServerStatus::Offline => "Offline",
+                                    },
+                                    cx,
+                                )
+                            }),
+                    )
+                    .child(dev_server_name.clone()),
+            )
+            .child(
+                h_flex()
+                    .ml_5()
+                    .gap_2()
+                    .child(self.remote_project_path_input.clone())
+                    .when(!*creating && remote_project.is_none(), |div| {
+                        div.child(Button::new("create-remote-server", "Create").on_click({
+                            let dev_server_id = *dev_server_id;
+                            cx.listener(move |this, _, cx| {
+                                this.create_remote_project(dev_server_id, cx)
+                            })
+                        }))
+                    })
+                    .when(*creating, |div| {
+                        div.child(Button::new("create-dev-server", "Creating...").disabled(true))
+                    }),
+            )
+            .when_some(remote_project.clone(), |div, remote_project| {
+                let status = self
+                    .remote_project_store
+                    .read(cx)
+                    .remote_project(RemoteProjectId(remote_project.id))
+                    .map(|project| {
+                        if project.project_id.is_some() {
+                            DevServerStatus::Online
+                        } else {
+                            DevServerStatus::Offline
+                        }
+                    })
+                    .unwrap_or(DevServerStatus::Offline);
+                div.child(
+                    v_flex()
+                        .ml_5()
+                        .ml_8()
+                        .gap_2()
+                        .when(status == DevServerStatus::Offline, |this| {
+                            this.child(Label::new("Waiting for project..."))
+                        })
+                        .when(status == DevServerStatus::Online, |this| {
+                            this.child(Label::new("Project online! 🎊")).child(
+                                Button::new("done", "Done").on_click(cx.listener(|_, _, cx| {
+                                    cx.dispatch_action(menu::Cancel.boxed_clone())
+                                })),
+                            )
+                        }),
+                )
+            })
+    }
+}
+impl ModalView for RemoteProjects {}
+
+impl FocusableView for RemoteProjects {
+    fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
+        self.focus_handle.clone()
+    }
+}
+
+impl EventEmitter<DismissEvent> for RemoteProjects {}
+
+impl Render for RemoteProjects {
+    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
+        div()
+            .track_focus(&self.focus_handle)
+            .elevation_3(cx)
+            .key_context("DevServerModal")
+            .on_action(cx.listener(Self::cancel))
+            .on_action(cx.listener(Self::confirm))
+            .on_mouse_down_out(cx.listener(|this, _, cx| {
+                if matches!(this.mode, Mode::Default) {
+                    cx.emit(DismissEvent)
+                }
+            }))
+            .pb_4()
+            .w(rems(34.))
+            .min_h(rems(20.))
+            .max_h(rems(40.))
+            .child(match &self.mode {
+                Mode::Default => self.render_default(cx).into_any_element(),
+                Mode::CreateRemoteProject(_) => {
+                    self.render_create_remote_project(cx).into_any_element()
+                }
+                Mode::CreateDevServer(_) => self.render_create_dev_server(cx).into_any_element(),
+            })
+    }
+}

crates/remote_projects/Cargo.toml 🔗

@@ -0,0 +1,23 @@
+[package]
+name = "remote_projects"
+version = "0.1.0"
+edition = "2021"
+publish = false
+license = "GPL-3.0-or-later"
+
+[lints]
+workspace = true
+
+[lib]
+path = "src/remote_projects.rs"
+doctest = false
+
+[dependencies]
+anyhow.workspace = true
+gpui.workspace = true
+serde.workspace = true
+client.workspace = true
+rpc.workspace = true
+
+[dev-dependencies]
+serde_json.workspace = true

crates/remote_projects/src/remote_projects.rs 🔗

@@ -0,0 +1,186 @@
+use anyhow::Result;
+use gpui::{AppContext, AsyncAppContext, Context, Global, Model, ModelContext, SharedString, Task};
+use rpc::{
+    proto::{self, DevServerStatus},
+    TypedEnvelope,
+};
+use std::{collections::HashMap, sync::Arc};
+
+use client::{Client, ProjectId};
+pub use client::{DevServerId, RemoteProjectId};
+
+pub struct Store {
+    remote_projects: HashMap<RemoteProjectId, RemoteProject>,
+    dev_servers: HashMap<DevServerId, DevServer>,
+    _subscriptions: Vec<client::Subscription>,
+    client: Arc<Client>,
+}
+
+#[derive(Debug, Clone)]
+pub struct RemoteProject {
+    pub id: RemoteProjectId,
+    pub project_id: Option<ProjectId>,
+    pub path: SharedString,
+    pub dev_server_id: DevServerId,
+}
+
+impl From<proto::RemoteProject> for RemoteProject {
+    fn from(project: proto::RemoteProject) -> Self {
+        Self {
+            id: RemoteProjectId(project.id),
+            project_id: project.project_id.map(|id| ProjectId(id)),
+            path: project.path.into(),
+            dev_server_id: DevServerId(project.dev_server_id),
+        }
+    }
+}
+
+#[derive(Debug, Clone)]
+pub struct DevServer {
+    pub id: DevServerId,
+    pub name: SharedString,
+    pub status: DevServerStatus,
+}
+
+impl From<proto::DevServer> for DevServer {
+    fn from(dev_server: proto::DevServer) -> Self {
+        Self {
+            id: DevServerId(dev_server.dev_server_id),
+            status: dev_server.status(),
+            name: dev_server.name.into(),
+        }
+    }
+}
+
+struct GlobalStore(Model<Store>);
+
+impl Global for GlobalStore {}
+
+pub fn init(client: Arc<Client>, cx: &mut AppContext) {
+    let store = cx.new_model(|cx| Store::new(client, cx));
+    cx.set_global(GlobalStore(store));
+}
+
+impl Store {
+    pub fn global(cx: &AppContext) -> Model<Store> {
+        cx.global::<GlobalStore>().0.clone()
+    }
+
+    pub fn new(client: Arc<Client>, cx: &ModelContext<Self>) -> Self {
+        Self {
+            remote_projects: Default::default(),
+            dev_servers: Default::default(),
+            _subscriptions: vec![
+                client.add_message_handler(cx.weak_model(), Self::handle_remote_projects_update)
+            ],
+            client,
+        }
+    }
+
+    pub fn remote_projects_for_server(&self, id: DevServerId) -> Vec<RemoteProject> {
+        let mut projects: Vec<RemoteProject> = self
+            .remote_projects
+            .values()
+            .filter(|project| project.dev_server_id == id)
+            .cloned()
+            .collect();
+        projects.sort_by_key(|p| (p.path.clone(), p.id));
+        projects
+    }
+
+    pub fn dev_servers(&self) -> Vec<DevServer> {
+        let mut dev_servers: Vec<DevServer> = self.dev_servers.values().cloned().collect();
+        dev_servers.sort_by_key(|d| (d.status == DevServerStatus::Offline, d.name.clone(), d.id));
+        dev_servers
+    }
+
+    pub fn dev_server(&self, id: DevServerId) -> Option<&DevServer> {
+        self.dev_servers.get(&id)
+    }
+
+    pub fn dev_server_status(&self, id: DevServerId) -> DevServerStatus {
+        self.dev_server(id)
+            .map(|server| server.status)
+            .unwrap_or(DevServerStatus::Offline)
+    }
+
+    pub fn remote_projects(&self) -> Vec<RemoteProject> {
+        let mut projects: Vec<RemoteProject> = self.remote_projects.values().cloned().collect();
+        projects.sort_by_key(|p| (p.path.clone(), p.id));
+        projects
+    }
+
+    pub fn remote_project(&self, id: RemoteProjectId) -> Option<&RemoteProject> {
+        self.remote_projects.get(&id)
+    }
+
+    async fn handle_remote_projects_update(
+        this: Model<Self>,
+        envelope: TypedEnvelope<proto::RemoteProjectsUpdate>,
+        _: Arc<Client>,
+        mut cx: AsyncAppContext,
+    ) -> Result<()> {
+        this.update(&mut cx, |this, cx| {
+            this.dev_servers = envelope
+                .payload
+                .dev_servers
+                .into_iter()
+                .map(|dev_server| (DevServerId(dev_server.dev_server_id), dev_server.into()))
+                .collect();
+            this.remote_projects = envelope
+                .payload
+                .remote_projects
+                .into_iter()
+                .map(|project| (RemoteProjectId(project.id), project.into()))
+                .collect();
+
+            cx.notify();
+        })?;
+        Ok(())
+    }
+
+    pub fn create_remote_project(
+        &mut self,
+        dev_server_id: DevServerId,
+        path: String,
+        cx: &mut ModelContext<Self>,
+    ) -> Task<Result<proto::CreateRemoteProjectResponse>> {
+        let client = self.client.clone();
+        cx.background_executor().spawn(async move {
+            client
+                .request(proto::CreateRemoteProject {
+                    dev_server_id: dev_server_id.0,
+                    path,
+                })
+                .await
+        })
+    }
+
+    pub fn create_dev_server(
+        &mut self,
+        name: String,
+        cx: &mut ModelContext<Self>,
+    ) -> Task<Result<proto::CreateDevServerResponse>> {
+        let client = self.client.clone();
+        cx.background_executor().spawn(async move {
+            let result = client.request(proto::CreateDevServer { name }).await?;
+            Ok(result)
+        })
+    }
+
+    pub fn delete_dev_server(
+        &mut self,
+        id: DevServerId,
+        cx: &mut ModelContext<Self>,
+    ) -> Task<Result<()>> {
+        let client = self.client.clone();
+        cx.background_executor().spawn(async move {
+            client
+                .request(proto::DeleteDevServer {
+                    dev_server_id: id.0,
+                })
+                .await?;
+            Ok(())
+        })
+    }
+}

crates/rpc/proto/zed.proto 🔗

@@ -233,6 +233,10 @@ message Envelope {
         JoinRemoteProject join_remote_project = 185;
         RejoinRemoteProjects rejoin_remote_projects = 186;
         RejoinRemoteProjectsResponse rejoin_remote_projects_response = 187;
+
+        RemoteProjectsUpdate remote_projects_update = 193;
+        ValidateRemoteProjectRequest validate_remote_project_request = 194; // Current max
+        DeleteDevServer delete_dev_server = 195;
     }
 
     reserved 158 to 161;
@@ -269,6 +273,8 @@ enum ErrorCode {
     UnsharedItem = 12;
     NoSuchProject = 13;
     DevServerAlreadyOnline = 14;
+    DevServerOffline = 15;
+    RemoteProjectPathDoesNotExist = 16;
     reserved 6;
 }
 
@@ -433,6 +439,7 @@ message LiveKitConnectionInfo {
 message ShareProject {
     uint64 room_id = 1;
     repeated WorktreeMetadata worktrees = 2;
+    optional uint64 remote_project_id = 3;
 }
 
 message ShareProjectResponse {
@@ -457,8 +464,8 @@ message JoinHostedProject {
 }
 
 message CreateRemoteProject {
-    uint64 channel_id = 1;
-    string name = 2;
+    reserved 1;
+    reserved 2;
     uint64 dev_server_id = 3;
     string path = 4;
 }
@@ -466,14 +473,18 @@ message CreateRemoteProjectResponse {
     RemoteProject remote_project = 1;
 }
 
+message ValidateRemoteProjectRequest {
+    string path = 1;
+}
+
 message CreateDevServer {
-    uint64 channel_id = 1;
+    reserved 1;
     string name = 2;
 }
 
 message CreateDevServerResponse {
     uint64 dev_server_id = 1;
-    uint64 channel_id = 2;
+    reserved 2;
     string access_token = 3;
     string name = 4;
 }
@@ -481,6 +492,10 @@ message CreateDevServerResponse {
 message ShutdownDevServer {
 }
 
+message DeleteDevServer {
+    uint64 dev_server_id = 1;
+}
+
 message ReconnectDevServer {
     repeated UpdateProject reshared_projects = 1;
 }
@@ -493,6 +508,11 @@ message DevServerInstructions {
     repeated RemoteProject projects = 1;
 }
 
+message RemoteProjectsUpdate {
+    repeated DevServer dev_servers = 1;
+    repeated RemoteProject remote_projects = 2;
+}
+
 message ShareRemoteProject {
     uint64 remote_project_id = 1;
     repeated WorktreeMetadata worktrees = 2;
@@ -509,6 +529,7 @@ message JoinProjectResponse {
     repeated Collaborator collaborators = 3;
     repeated LanguageServer language_servers = 4;
     ChannelRole role = 6;
+    optional uint64 remote_project_id = 7;
 }
 
 message LeaveProject {
@@ -1131,11 +1152,10 @@ message UpdateChannels {
     repeated HostedProject hosted_projects = 10;
     repeated uint64 deleted_hosted_projects = 11;
 
-    repeated DevServer dev_servers = 12;
-    repeated uint64 deleted_dev_servers = 13;
-
-    repeated RemoteProject remote_projects = 14;
-    repeated uint64 deleted_remote_projects = 15;
+    reserved 12;
+    reserved 13;
+    reserved 14;
+    reserved 15;
 }
 
 message UpdateUserChannels {
@@ -1174,14 +1194,14 @@ message HostedProject {
 message RemoteProject {
     uint64 id = 1;
     optional uint64 project_id = 2;
-    uint64 channel_id = 3;
-    string name = 4;
+    reserved 3;
+    reserved 4;
     uint64 dev_server_id = 5;
     string path = 6;
 }
 
 message DevServer {
-    uint64 channel_id = 1;
+    reserved 1;
     uint64 dev_server_id = 2;
     string name = 3;
     DevServerStatus status = 4;

crates/rpc/src/proto.rs 🔗

@@ -303,7 +303,7 @@ messages!(
     (SetRoomParticipantRole, Foreground),
     (BlameBuffer, Foreground),
     (BlameBufferResponse, Foreground),
-    (CreateRemoteProject, Foreground),
+    (CreateRemoteProject, Background),
     (CreateRemoteProjectResponse, Foreground),
     (CreateDevServer, Foreground),
     (CreateDevServerResponse, Foreground),
@@ -317,6 +317,9 @@ messages!(
     (RejoinRemoteProjectsResponse, Foreground),
     (MultiLspQuery, Background),
     (MultiLspQueryResponse, Background),
+    (RemoteProjectsUpdate, Foreground),
+    (ValidateRemoteProjectRequest, Background),
+    (DeleteDevServer, Foreground)
 );
 
 request_messages!(
@@ -417,7 +420,9 @@ request_messages!(
     (JoinRemoteProject, JoinProjectResponse),
     (RejoinRemoteProjects, RejoinRemoteProjectsResponse),
     (ReconnectDevServer, ReconnectDevServerResponse),
+    (ValidateRemoteProjectRequest, Ack),
     (MultiLspQuery, MultiLspQueryResponse),
+    (DeleteDevServer, Ack),
 );
 
 entity_messages!(

crates/sqlez/src/connection.rs 🔗

@@ -105,51 +105,50 @@ impl Connection {
                 let mut raw_statement = ptr::null_mut::<sqlite3_stmt>();
                 let mut remaining_sql_ptr = ptr::null();
 
-                let (res, offset, message, _conn) = if let Some(table_to_alter) = alter_table {
-                    // ALTER TABLE is a weird statement. When preparing the statement the table's
-                    // existence is checked *before* syntax checking any other part of the statement.
-                    // Therefore, we need to make sure that the table has been created before calling
-                    // prepare. As we don't want to trash whatever database this is connected to, we
-                    // create a new in-memory DB to test.
-
-                    let temp_connection = Connection::open_memory(None);
-                    //This should always succeed, if it doesn't then you really should know about it
-                    temp_connection
-                        .exec(&format!(
-                        "CREATE TABLE {table_to_alter}(__place_holder_column_for_syntax_checking)"
-                    ))
-                        .unwrap()()
-                    .unwrap();
-
-                    sqlite3_prepare_v2(
-                        temp_connection.sqlite3,
-                        remaining_sql.as_ptr(),
-                        -1,
-                        &mut raw_statement,
-                        &mut remaining_sql_ptr,
-                    );
-
-                    (
-                        sqlite3_errcode(temp_connection.sqlite3),
-                        sqlite3_error_offset(temp_connection.sqlite3),
-                        sqlite3_errmsg(temp_connection.sqlite3),
-                        Some(temp_connection),
-                    )
-                } else {
-                    sqlite3_prepare_v2(
-                        self.sqlite3,
-                        remaining_sql.as_ptr(),
-                        -1,
-                        &mut raw_statement,
-                        &mut remaining_sql_ptr,
-                    );
-                    (
-                        sqlite3_errcode(self.sqlite3),
-                        sqlite3_error_offset(self.sqlite3),
-                        sqlite3_errmsg(self.sqlite3),
-                        None,
-                    )
-                };
+                let (res, offset, message, _conn) =
+                    if let Some((table_to_alter, column)) = alter_table {
+                        // ALTER TABLE is a weird statement. When preparing the statement the table's
+                        // existence is checked *before* syntax checking any other part of the statement.
+                        // Therefore, we need to make sure that the table has been created before calling
+                        // prepare. As we don't want to trash whatever database this is connected to, we
+                        // create a new in-memory DB to test.
+
+                        let temp_connection = Connection::open_memory(None);
+                        //This should always succeed, if it doesn't then you really should know about it
+                        temp_connection
+                            .exec(&format!("CREATE TABLE {table_to_alter}({column})"))
+                            .unwrap()()
+                        .unwrap();
+
+                        sqlite3_prepare_v2(
+                            temp_connection.sqlite3,
+                            remaining_sql.as_ptr(),
+                            -1,
+                            &mut raw_statement,
+                            &mut remaining_sql_ptr,
+                        );
+
+                        (
+                            sqlite3_errcode(temp_connection.sqlite3),
+                            sqlite3_error_offset(temp_connection.sqlite3),
+                            sqlite3_errmsg(temp_connection.sqlite3),
+                            Some(temp_connection),
+                        )
+                    } else {
+                        sqlite3_prepare_v2(
+                            self.sqlite3,
+                            remaining_sql.as_ptr(),
+                            -1,
+                            &mut raw_statement,
+                            &mut remaining_sql_ptr,
+                        );
+                        (
+                            sqlite3_errcode(self.sqlite3),
+                            sqlite3_error_offset(self.sqlite3),
+                            sqlite3_errmsg(self.sqlite3),
+                            None,
+                        )
+                    };
 
                 sqlite3_finalize(raw_statement);
 
@@ -203,7 +202,7 @@ impl Connection {
     }
 }
 
-fn parse_alter_table(remaining_sql_str: &str) -> Option<String> {
+fn parse_alter_table(remaining_sql_str: &str) -> Option<(String, String)> {
     let remaining_sql_str = remaining_sql_str.to_lowercase();
     if remaining_sql_str.starts_with("alter") {
         if let Some(table_offset) = remaining_sql_str.find("table") {
@@ -215,7 +214,19 @@ fn parse_alter_table(remaining_sql_str: &str) -> Option<String> {
                 .take_while(|c| !c.is_whitespace())
                 .collect::<String>();
             if !table_to_alter.is_empty() {
-                return Some(table_to_alter);
+                let column_name =
+                    if let Some(rename_offset) = remaining_sql_str.find("rename column") {
+                        let after_rename_offset = rename_offset + "rename column".len();
+                        remaining_sql_str
+                            .chars()
+                            .skip(after_rename_offset)
+                            .skip_while(|c| c.is_whitespace())
+                            .take_while(|c| !c.is_whitespace())
+                            .collect::<String>()
+                    } else {
+                        "__place_holder_column_for_syntax_checking".to_string()
+                    };
+                return Some((table_to_alter, column_name));
             }
         }
     }

crates/sqlez/src/statement.rs 🔗

@@ -320,6 +320,7 @@ impl<'a> Statement<'a> {
             this: &mut Statement,
             callback: impl FnOnce(&mut Statement) -> Result<R>,
         ) -> Result<R> {
+            println!("{:?}", std::any::type_name::<R>());
             if this.step()? != StepResult::Row {
                 return Err(anyhow!("single called with query that returns no rows."));
             }

crates/tasks_ui/src/modal.rs 🔗

@@ -330,6 +330,7 @@ impl PickerDelegate for TasksModalDelegate {
             text: hit.string.clone(),
             highlight_positions: hit.positions.clone(),
             char_count: hit.string.chars().count(),
+            color: Color::Default,
         };
         let icon = match source_kind {
             TaskSourceKind::UserInput => Some(Icon::new(IconName::Terminal)),

crates/ui/src/components/icon.rs 🔗

@@ -1,7 +1,7 @@
-use gpui::{svg, IntoElement, Rems, Transformation};
+use gpui::{svg, Hsla, IntoElement, Rems, Transformation};
 use strum::EnumIter;
 
-use crate::prelude::*;
+use crate::{prelude::*, Indicator};
 
 #[derive(Default, PartialEq, Copy, Clone)]
 pub enum IconSize {
@@ -283,3 +283,63 @@ impl RenderOnce for Icon {
             .text_color(self.color.color(cx))
     }
 }
+
+#[derive(IntoElement)]
+pub struct IconWithIndicator {
+    icon: Icon,
+    indicator: Option<Indicator>,
+    indicator_border_color: Option<Hsla>,
+}
+
+impl IconWithIndicator {
+    pub fn new(icon: Icon, indicator: Option<Indicator>) -> Self {
+        Self {
+            icon,
+            indicator,
+            indicator_border_color: None,
+        }
+    }
+
+    pub fn indicator(mut self, indicator: Option<Indicator>) -> Self {
+        self.indicator = indicator;
+        self
+    }
+
+    pub fn indicator_color(mut self, color: Color) -> Self {
+        if let Some(indicator) = self.indicator.as_mut() {
+            indicator.color = color;
+        }
+        self
+    }
+
+    pub fn indicator_border_color(mut self, color: Option<Hsla>) -> Self {
+        self.indicator_border_color = color;
+        self
+    }
+}
+
+impl RenderOnce for IconWithIndicator {
+    fn render(self, cx: &mut WindowContext) -> impl IntoElement {
+        let indicator_border_color = self
+            .indicator_border_color
+            .unwrap_or_else(|| cx.theme().colors().elevated_surface_background);
+
+        div()
+            .relative()
+            .child(self.icon)
+            .when_some(self.indicator, |this, indicator| {
+                this.child(
+                    div()
+                        .absolute()
+                        .w_2()
+                        .h_2()
+                        .border()
+                        .border_color(indicator_border_color)
+                        .rounded_full()
+                        .neg_bottom_0p5()
+                        .neg_right_1()
+                        .child(indicator),
+                )
+            })
+    }
+}

crates/ui/src/components/modal.rs 🔗

@@ -1,12 +1,16 @@
-use gpui::*;
+use gpui::{prelude::FluentBuilder, *};
 use smallvec::SmallVec;
 
-use crate::{h_flex, IconButton, IconButtonShape, IconName, Label, LabelCommon, LabelSize};
+use crate::{
+    h_flex, Clickable, IconButton, IconButtonShape, IconName, Label, LabelCommon, LabelSize,
+};
 
 #[derive(IntoElement)]
 pub struct ModalHeader {
     id: ElementId,
     children: SmallVec<[AnyElement; 2]>,
+    show_dismiss_button: bool,
+    show_back_button: bool,
 }
 
 impl ModalHeader {
@@ -14,8 +18,20 @@ impl ModalHeader {
         Self {
             id: id.into(),
             children: SmallVec::new(),
+            show_dismiss_button: false,
+            show_back_button: false,
         }
     }
+
+    pub fn show_dismiss_button(mut self, show: bool) -> Self {
+        self.show_dismiss_button = show;
+        self
+    }
+
+    pub fn show_back_button(mut self, show: bool) -> Self {
+        self.show_back_button = show;
+        self
+    }
 }
 
 impl ParentElement for ModalHeader {
@@ -31,9 +47,28 @@ impl RenderOnce for ModalHeader {
             .w_full()
             .px_2()
             .py_1p5()
+            .when(self.show_back_button, |this| {
+                this.child(
+                    div().pr_1().child(
+                        IconButton::new("back", IconName::ArrowLeft)
+                            .shape(IconButtonShape::Square)
+                            .on_click(|_, cx| {
+                                cx.dispatch_action(menu::Cancel.boxed_clone());
+                            }),
+                    ),
+                )
+            })
             .child(div().flex_1().children(self.children))
             .justify_between()
-            .child(IconButton::new("dismiss", IconName::Close).shape(IconButtonShape::Square))
+            .when(self.show_dismiss_button, |this| {
+                this.child(
+                    IconButton::new("dismiss", IconName::Close)
+                        .shape(IconButtonShape::Square)
+                        .on_click(|_, cx| {
+                            cx.dispatch_action(menu::Cancel.boxed_clone());
+                        }),
+                )
+            })
     }
 }
 

crates/ui_text_field/src/ui_text_field.rs 🔗

@@ -44,6 +44,8 @@ pub struct TextField {
     start_icon: Option<IconName>,
     /// The layout of the label relative to the text field.
     with_label: FieldLabelLayout,
+    /// Whether the text field is disabled.
+    disabled: bool,
 }
 
 impl FocusableView for TextField {
@@ -72,6 +74,7 @@ impl TextField {
             editor,
             start_icon: None,
             with_label: FieldLabelLayout::Hidden,
+            disabled: false,
         }
     }
 
@@ -84,6 +87,16 @@ impl TextField {
         self.with_label = layout;
         self
     }
+
+    pub fn set_disabled(&mut self, disabled: bool, cx: &mut ViewContext<Self>) {
+        self.disabled = disabled;
+        self.editor
+            .update(cx, |editor, _| editor.set_read_only(disabled))
+    }
+
+    pub fn editor(&self) -> &View<Editor> {
+        &self.editor
+    }
 }
 
 impl Render for TextField {
@@ -91,17 +104,17 @@ impl Render for TextField {
         let settings = ThemeSettings::get_global(cx);
         let theme_color = cx.theme().colors();
 
-        let style = TextFieldStyle {
+        let mut style = TextFieldStyle {
             text_color: theme_color.text,
             background_color: theme_color.ghost_element_background,
             border_color: theme_color.border,
         };
 
-        // if self.disabled {
-        //     style.text_color = theme_color.text_disabled;
-        //     style.background_color = theme_color.ghost_element_disabled;
-        //     style.border_color = theme_color.border_disabled;
-        // }
+        if self.disabled {
+            style.text_color = theme_color.text_disabled;
+            style.background_color = theme_color.ghost_element_disabled;
+            style.border_color = theme_color.border_disabled;
+        }
 
         // if self.error_message.is_some() {
         //     style.text_color = cx.theme().status().error;
@@ -131,7 +144,15 @@ impl Render for TextField {
             .group("text-field")
             .w_full()
             .when(self.with_label == FieldLabelLayout::Stacked, |this| {
-                this.child(Label::new(self.label.clone()).size(LabelSize::Default))
+                this.child(
+                    Label::new(self.label.clone())
+                        .size(LabelSize::Default)
+                        .color(if self.disabled {
+                            Color::Disabled
+                        } else {
+                            Color::Muted
+                        }),
+                )
             })
             .child(
                 v_flex().w_full().child(

crates/workspace/Cargo.toml 🔗

@@ -45,6 +45,7 @@ node_runtime.workspace = true
 parking_lot.workspace = true
 postage.workspace = true
 project.workspace = true
+remote_projects.workspace = true
 task.workspace = true
 schemars.workspace = true
 serde.workspace = true

crates/workspace/src/item.rs 🔗

@@ -513,8 +513,9 @@ impl<T: Item> ItemHandle for View<T> {
                 }));
             }
 
-            let mut event_subscription =
-                Some(cx.subscribe(self, move |workspace, item, event, cx| {
+            let mut event_subscription = Some(cx.subscribe(
+                self,
+                move |workspace, item: View<T>, event, cx| {
                     let pane = if let Some(pane) = workspace
                         .panes_by_item
                         .get(&item.item_id())
@@ -575,7 +576,8 @@ impl<T: Item> ItemHandle for View<T> {
 
                         _ => {}
                     });
-                }));
+                },
+            ));
 
             cx.on_blur(&self.focus_handle(cx), move |workspace, cx| {
                 if WorkspaceSettings::get_global(cx).autosave == AutosaveSetting::OnFocusChange {

crates/workspace/src/persistence.rs 🔗

@@ -3,6 +3,7 @@ pub mod model;
 use std::path::Path;
 
 use anyhow::{anyhow, bail, Context, Result};
+use client::RemoteProjectId;
 use db::{define_connection, query, sqlez::connection::Connection, sqlez_macros::sql};
 use gpui::{point, size, Axis, Bounds};
 
@@ -17,11 +18,11 @@ use uuid::Uuid;
 use crate::WorkspaceId;
 
 use model::{
-    GroupId, PaneId, SerializedItem, SerializedPane, SerializedPaneGroup, SerializedWorkspace,
-    WorkspaceLocation,
+    GroupId, LocalPaths, PaneId, SerializedItem, SerializedPane, SerializedPaneGroup,
+    SerializedWorkspace,
 };
 
-use self::model::DockStructure;
+use self::model::{DockStructure, SerializedRemoteProject, SerializedWorkspaceLocation};
 
 #[derive(Copy, Clone, Debug, PartialEq)]
 pub(crate) struct SerializedAxis(pub(crate) gpui::Axis);
@@ -125,7 +126,7 @@ define_connection! {
     //
     // workspaces(
     //   workspace_id: usize, // Primary key for workspaces
-    //   workspace_location: Bincode<Vec<PathBuf>>,
+    //   local_paths: Bincode<Vec<PathBuf>>,
     //   dock_visible: bool, // Deprecated
     //   dock_anchor: DockAnchor, // Deprecated
     //   dock_pane: Option<usize>, // Deprecated
@@ -289,6 +290,15 @@ define_connection! {
     sql!(
         ALTER TABLE workspaces ADD COLUMN centered_layout INTEGER; //bool
     ),
+    sql!(
+        CREATE TABLE remote_projects (
+            remote_project_id INTEGER NOT NULL UNIQUE,
+            path TEXT,
+            dev_server_name TEXT
+        );
+        ALTER TABLE workspaces ADD COLUMN remote_project_id INTEGER;
+        ALTER TABLE workspaces RENAME COLUMN workspace_location TO local_paths;
+    ),
     ];
 }
 
@@ -300,13 +310,23 @@ impl WorkspaceDb {
         &self,
         worktree_roots: &[P],
     ) -> Option<SerializedWorkspace> {
-        let workspace_location: WorkspaceLocation = worktree_roots.into();
+        let local_paths = LocalPaths::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, workspace_location, bounds, display, fullscreen, centered_layout, docks): (
+        let (
+            workspace_id,
+            local_paths,
+            remote_project_id,
+            bounds,
+            display,
+            fullscreen,
+            centered_layout,
+            docks,
+        ): (
             WorkspaceId,
-            WorkspaceLocation,
+            Option<LocalPaths>,
+            Option<u64>,
             Option<SerializedWindowsBounds>,
             Option<Uuid>,
             Option<bool>,
@@ -316,7 +336,8 @@ impl WorkspaceDb {
             .select_row_bound(sql! {
                 SELECT
                     workspace_id,
-                    workspace_location,
+                    local_paths,
+                    remote_project_id,
                     window_state,
                     window_x,
                     window_y,
@@ -335,16 +356,34 @@ impl WorkspaceDb {
                     bottom_dock_active_panel,
                     bottom_dock_zoom
                 FROM workspaces
-                WHERE workspace_location = ?
+                WHERE local_paths = ?
             })
-            .and_then(|mut prepared_statement| (prepared_statement)(&workspace_location))
+            .and_then(|mut prepared_statement| (prepared_statement)(&local_paths))
             .context("No workspaces found")
             .warn_on_err()
             .flatten()?;
 
+        let location = if let Some(remote_project_id) = remote_project_id {
+            let remote_project: SerializedRemoteProject = self
+                .select_row_bound(sql! {
+                    SELECT remote_project_id, path, dev_server_name
+                    FROM remote_projects
+                    WHERE remote_project_id = ?
+                })
+                .and_then(|mut prepared_statement| (prepared_statement)(remote_project_id))
+                .context("No remote project found")
+                .warn_on_err()
+                .flatten()?;
+            SerializedWorkspaceLocation::Remote(remote_project)
+        } else if let Some(local_paths) = local_paths {
+            SerializedWorkspaceLocation::Local(local_paths)
+        } else {
+            return None;
+        };
+
         Some(SerializedWorkspace {
             id: workspace_id,
-            location: workspace_location.clone(),
+            location,
             center_group: self
                 .get_center_pane_group(workspace_id)
                 .context("Getting center group")
@@ -368,43 +407,102 @@ impl WorkspaceDb {
                     DELETE FROM panes WHERE workspace_id = ?1;))?(workspace.id)
                 .context("Clearing old panes")?;
 
-                conn.exec_bound(sql!(
-                    DELETE FROM workspaces WHERE workspace_location = ? AND workspace_id != ?
-                ))?((&workspace.location, workspace.id))
-                .context("clearing out old locations")?;
-
-                // Upsert
-                conn.exec_bound(sql!(
-                    INSERT INTO workspaces(
-                        workspace_id,
-                        workspace_location,
-                        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
-                        workspace_location = ?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, &workspace.location, workspace.docks))
-                .context("Updating workspace")?;
+                match workspace.location {
+                    SerializedWorkspaceLocation::Local(local_paths) => {
+                        conn.exec_bound(sql!(
+                            DELETE FROM workspaces WHERE local_paths = ? AND workspace_id != ?
+                        ))?((&local_paths, workspace.id))
+                        .context("clearing out old locations")?;
+
+                        // Upsert
+                        conn.exec_bound(sql!(
+                            INSERT INTO workspaces(
+                                workspace_id,
+                                local_paths,
+                                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
+                                local_paths = ?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, &local_paths, workspace.docks))
+                        .context("Updating workspace")?;
+                    }
+                    SerializedWorkspaceLocation::Remote(remote_project) => {
+                        conn.exec_bound(sql!(
+                            DELETE FROM workspaces WHERE remote_project_id = ? AND workspace_id != ?
+                        ))?((remote_project.id.0, workspace.id))
+                        .context("clearing out old locations")?;
+
+                        conn.exec_bound(sql!(
+                            INSERT INTO remote_projects(
+                                remote_project_id,
+                                path,
+                                dev_server_name
+                            ) VALUES (?1, ?2, ?3)
+                            ON CONFLICT DO
+                            UPDATE SET
+                                path = ?2,
+                                dev_server_name = ?3
+                        ))?(&remote_project)?;
+
+                        // Upsert
+                        conn.exec_bound(sql!(
+                            INSERT INTO workspaces(
+                                workspace_id,
+                                remote_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
+                                remote_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,
+                            remote_project.id.0,
+                            workspace.docks,
+                        ))
+                        .context("Updating workspace")?;
+                    }
+                }
 
                 // Save center pane group
                 Self::save_pane_group(conn, workspace.id, &workspace.center_group, None)
@@ -424,24 +522,43 @@ impl WorkspaceDb {
     }
 
     query! {
-        fn recent_workspaces() -> Result<Vec<(WorkspaceId, WorkspaceLocation)>> {
-            SELECT workspace_id, workspace_location
+        fn recent_workspaces() -> Result<Vec<(WorkspaceId, LocalPaths, Option<u64>)>> {
+            SELECT workspace_id, local_paths, remote_project_id
             FROM workspaces
-            WHERE workspace_location IS NOT NULL
+            WHERE local_paths IS NOT NULL OR remote_project_id IS NOT NULL
             ORDER BY timestamp DESC
         }
     }
 
     query! {
-        pub fn last_window() -> Result<(Option<Uuid>, Option<SerializedWindowsBounds>, Option<bool>)> {
-            SELECT display, window_state, window_x, window_y, window_width, window_height, fullscreen
-            FROM workspaces
-            WHERE workspace_location IS NOT NULL
-            ORDER BY timestamp DESC
-            LIMIT 1
+        fn remote_projects() -> Result<Vec<SerializedRemoteProject>> {
+            SELECT remote_project_id, path, dev_server_name
+            FROM remote_projects
         }
     }
 
+    pub(crate) fn last_window(
+        &self,
+    ) -> anyhow::Result<(Option<Uuid>, Option<SerializedWindowsBounds>, Option<bool>)> {
+        let mut prepared_query =
+            self.select::<(Option<Uuid>, Option<SerializedWindowsBounds>, Option<bool>)>(sql!(
+                SELECT
+                display,
+                window_state, window_x, window_y, window_width, window_height,
+                fullscreen
+                FROM workspaces
+                WHERE local_paths
+                IS NOT NULL
+                ORDER BY timestamp DESC
+                LIMIT 1
+            ))?;
+        let result = prepared_query()?;
+        Ok(result
+            .into_iter()
+            .next()
+            .unwrap_or_else(|| (None, None, None)))
+    }
+
     query! {
         pub async fn delete_workspace_by_id(id: WorkspaceId) -> Result<()> {
             DELETE FROM workspaces
@@ -451,14 +568,29 @@ impl WorkspaceDb {
 
     // 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, WorkspaceLocation)>> {
+    pub async fn recent_workspaces_on_disk(
+        &self,
+    ) -> Result<Vec<(WorkspaceId, SerializedWorkspaceLocation)>> {
         let mut result = Vec::new();
         let mut delete_tasks = Vec::new();
-        for (id, location) in self.recent_workspaces()? {
+        let remote_projects = self.remote_projects()?;
+
+        for (id, location, remote_project_id) in self.recent_workspaces()? {
+            if let Some(remote_project_id) = remote_project_id.map(RemoteProjectId) {
+                if let Some(remote_project) =
+                    remote_projects.iter().find(|rp| rp.id == remote_project_id)
+                {
+                    result.push((id, remote_project.clone().into()));
+                } 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())
             {
-                result.push((id, location));
+                result.push((id, location.into()));
             } else {
                 delete_tasks.push(self.delete_workspace_by_id(id));
             }
@@ -468,13 +600,16 @@ impl WorkspaceDb {
         Ok(result)
     }
 
-    pub async fn last_workspace(&self) -> Result<Option<WorkspaceLocation>> {
+    pub async fn last_workspace(&self) -> Result<Option<LocalPaths>> {
         Ok(self
             .recent_workspaces_on_disk()
             .await?
             .into_iter()
-            .next()
-            .map(|(_, location)| location))
+            .filter_map(|(_, location)| match location {
+                SerializedWorkspaceLocation::Local(local_paths) => Some(local_paths),
+                SerializedWorkspaceLocation::Remote(_) => None,
+            })
+            .next())
     }
 
     fn get_center_pane_group(&self, workspace_id: WorkspaceId) -> Result<SerializedPaneGroup> {
@@ -774,7 +909,7 @@ mod tests {
 
         let mut workspace_1 = SerializedWorkspace {
             id: WorkspaceId(1),
-            location: (["/tmp", "/tmp2"]).into(),
+            location: LocalPaths::new(["/tmp", "/tmp2"]).into(),
             center_group: Default::default(),
             bounds: Default::default(),
             display: Default::default(),
@@ -785,7 +920,7 @@ mod tests {
 
         let workspace_2 = SerializedWorkspace {
             id: WorkspaceId(2),
-            location: (["/tmp"]).into(),
+            location: LocalPaths::new(["/tmp"]).into(),
             center_group: Default::default(),
             bounds: Default::default(),
             display: Default::default(),
@@ -812,7 +947,7 @@ mod tests {
         })
         .await;
 
-        workspace_1.location = (["/tmp", "/tmp3"]).into();
+        workspace_1.location = LocalPaths::new(["/tmp", "/tmp3"]).into();
         db.save_workspace(workspace_1.clone()).await;
         db.save_workspace(workspace_1).await;
         db.save_workspace(workspace_2).await;
@@ -885,7 +1020,7 @@ mod tests {
 
         let workspace = SerializedWorkspace {
             id: WorkspaceId(5),
-            location: (["/tmp", "/tmp2"]).into(),
+            location: LocalPaths::new(["/tmp", "/tmp2"]).into(),
             center_group,
             bounds: Default::default(),
             display: Default::default(),
@@ -915,7 +1050,7 @@ mod tests {
 
         let workspace_1 = SerializedWorkspace {
             id: WorkspaceId(1),
-            location: (["/tmp", "/tmp2"]).into(),
+            location: LocalPaths::new(["/tmp", "/tmp2"]).into(),
             center_group: Default::default(),
             bounds: Default::default(),
             display: Default::default(),
@@ -926,7 +1061,7 @@ mod tests {
 
         let mut workspace_2 = SerializedWorkspace {
             id: WorkspaceId(2),
-            location: (["/tmp"]).into(),
+            location: LocalPaths::new(["/tmp"]).into(),
             center_group: Default::default(),
             bounds: Default::default(),
             display: Default::default(),
@@ -953,7 +1088,7 @@ mod tests {
         assert_eq!(db.workspace_for_roots(&["/tmp3", "/tmp2", "/tmp4"]), None);
 
         // Test 'mutate' case of updating a pre-existing id
-        workspace_2.location = (["/tmp", "/tmp2"]).into();
+        workspace_2.location = LocalPaths::new(["/tmp", "/tmp2"]).into();
 
         db.save_workspace(workspace_2.clone()).await;
         assert_eq!(
@@ -964,7 +1099,7 @@ mod tests {
         // Test other mechanism for mutating
         let mut workspace_3 = SerializedWorkspace {
             id: WorkspaceId(3),
-            location: (&["/tmp", "/tmp2"]).into(),
+            location: LocalPaths::new(&["/tmp", "/tmp2"]).into(),
             center_group: Default::default(),
             bounds: Default::default(),
             display: Default::default(),
@@ -980,7 +1115,7 @@ mod tests {
         );
 
         // Make sure that updating paths differently also works
-        workspace_3.location = (["/tmp3", "/tmp4", "/tmp2"]).into();
+        workspace_3.location = LocalPaths::new(["/tmp3", "/tmp4", "/tmp2"]).into();
         db.save_workspace(workspace_3.clone()).await;
         assert_eq!(db.workspace_for_roots(&["/tmp2", "tmp"]), None);
         assert_eq!(
@@ -999,7 +1134,7 @@ mod tests {
     ) -> SerializedWorkspace {
         SerializedWorkspace {
             id: WorkspaceId(4),
-            location: workspace_id.into(),
+            location: LocalPaths::new(workspace_id).into(),
             center_group: center_group.clone(),
             bounds: Default::default(),
             display: Default::default(),

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

@@ -2,12 +2,14 @@ use super::SerializedAxis;
 use crate::{item::ItemHandle, ItemDeserializers, Member, Pane, PaneAxis, Workspace, WorkspaceId};
 use anyhow::{Context, Result};
 use async_recursion::async_recursion;
+use client::RemoteProjectId;
 use db::sqlez::{
     bindable::{Bind, Column, StaticColumnCount},
     statement::Statement,
 };
 use gpui::{AsyncWindowContext, Bounds, DevicePixels, Model, Task, View, WeakView};
 use project::Project;
+use serde::{Deserialize, Serialize};
 use std::{
     path::{Path, PathBuf},
     sync::Arc,
@@ -15,59 +17,98 @@ use std::{
 use util::ResultExt;
 use uuid::Uuid;
 
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct WorkspaceLocation(Arc<Vec<PathBuf>>);
+#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
+pub struct SerializedRemoteProject {
+    pub id: RemoteProjectId,
+    pub dev_server_name: String,
+    pub path: String,
+}
+
+#[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| p.as_ref().to_path_buf())
+            .collect();
+        paths.sort();
+        Self(Arc::new(paths))
+    }
 
-impl WorkspaceLocation {
     pub fn paths(&self) -> Arc<Vec<PathBuf>> {
         self.0.clone()
     }
+}
 
-    #[cfg(any(test, feature = "test-support"))]
-    pub fn new<P: AsRef<Path>>(paths: Vec<P>) -> Self {
-        Self(Arc::new(
-            paths
-                .into_iter()
-                .map(|p| p.as_ref().to_path_buf())
-                .collect(),
-        ))
+impl From<LocalPaths> for SerializedWorkspaceLocation {
+    fn from(local_paths: LocalPaths) -> Self {
+        Self::Local(local_paths)
     }
 }
 
-impl<P: AsRef<Path>, T: IntoIterator<Item = P>> From<T> for WorkspaceLocation {
-    fn from(iterator: T) -> Self {
-        let mut roots = iterator
-            .into_iter()
-            .map(|p| p.as_ref().to_path_buf())
-            .collect::<Vec<_>>();
-        roots.sort();
-        Self(Arc::new(roots))
+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 StaticColumnCount for WorkspaceLocation {}
-impl Bind for &WorkspaceLocation {
+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))
+    }
+}
+
+impl From<SerializedRemoteProject> for SerializedWorkspaceLocation {
+    fn from(remote_project: SerializedRemoteProject) -> Self {
+        Self::Remote(remote_project)
+    }
+}
+
+impl StaticColumnCount for SerializedRemoteProject {}
+impl Bind for &SerializedRemoteProject {
     fn bind(&self, statement: &Statement, start_index: i32) -> Result<i32> {
-        bincode::serialize(&self.0)
-            .expect("Bincode serialization of paths should not fail")
-            .bind(statement, start_index)
+        let next_index = statement.bind(&self.id.0, start_index)?;
+        let next_index = statement.bind(&self.dev_server_name, next_index)?;
+        statement.bind(&self.path, next_index)
     }
 }
 
-impl Column for WorkspaceLocation {
+impl Column for SerializedRemoteProject {
     fn column(statement: &mut Statement, start_index: i32) -> Result<(Self, i32)> {
-        let blob = statement.column_blob(start_index)?;
+        let id = statement.column_int64(start_index)?;
+        let dev_server_name = statement.column_text(start_index + 1)?.to_string();
+        let path = statement.column_text(start_index + 2)?.to_string();
         Ok((
-            WorkspaceLocation(bincode::deserialize(blob).context("Bincode failed")?),
-            start_index + 1,
+            Self {
+                id: RemoteProjectId(id as u64),
+                dev_server_name,
+                path,
+            },
+            start_index + 3,
         ))
     }
 }
 
+#[derive(Debug, PartialEq, Clone)]
+pub enum SerializedWorkspaceLocation {
+    Local(LocalPaths),
+    Remote(SerializedRemoteProject),
+}
+
 #[derive(Debug, PartialEq, Clone)]
 pub(crate) struct SerializedWorkspace {
     pub(crate) id: WorkspaceId,
-    pub(crate) location: WorkspaceLocation,
+    pub(crate) location: SerializedWorkspaceLocation,
     pub(crate) center_group: SerializedPaneGroup,
     pub(crate) bounds: Option<Bounds<DevicePixels>>,
     pub(crate) fullscreen: bool,

crates/workspace/src/workspace.rs 🔗

@@ -46,7 +46,7 @@ pub use pane::*;
 pub use pane_group::*;
 use persistence::{model::SerializedWorkspace, SerializedWindowsBounds, DB};
 pub use persistence::{
-    model::{ItemId, WorkspaceLocation},
+    model::{ItemId, LocalPaths, SerializedRemoteProject, SerializedWorkspaceLocation},
     WorkspaceDb, DB as WORKSPACE_DB,
 };
 use postage::stream::Stream;
@@ -82,7 +82,7 @@ use ui::{
     InteractiveElement as _, IntoElement, Label, ParentElement as _, Pixels, SharedString,
     Styled as _, ViewContext, VisualContext as _, WindowContext,
 };
-use util::ResultExt;
+use util::{maybe, ResultExt};
 use uuid::Uuid;
 pub use workspace_settings::{
     AutosaveSetting, RestoreOnStartupBehaviour, TabBarSettings, WorkspaceSettings,
@@ -3392,17 +3392,16 @@ impl Workspace {
         self.database_id
     }
 
-    fn location(&self, cx: &AppContext) -> Option<WorkspaceLocation> {
+    fn local_paths(&self, cx: &AppContext) -> Option<LocalPaths> {
         let project = self.project().read(cx);
 
         if project.is_local() {
-            Some(
+            Some(LocalPaths::new(
                 project
                     .visible_worktrees(cx)
                     .map(|worktree| worktree.read(cx).abs_path())
-                    .collect::<Vec<_>>()
-                    .into(),
-            )
+                    .collect::<Vec<_>>(),
+            ))
         } else {
             None
         }
@@ -3540,25 +3539,44 @@ impl Workspace {
             }
         }
 
-        if let Some(location) = self.location(cx) {
-            // Load bearing special case:
-            //  - with_local_workspace() relies on this to not have other stuff open
-            //    when you open your log
-            if !location.paths().is_empty() {
-                let center_group = build_serialized_pane_group(&self.center.root, cx);
-                let docks = build_serialized_docks(self, cx);
-                let serialized_workspace = SerializedWorkspace {
-                    id: self.database_id,
-                    location,
-                    center_group,
-                    bounds: Default::default(),
-                    display: Default::default(),
-                    docks,
-                    fullscreen: cx.is_fullscreen(),
-                    centered_layout: self.centered_layout,
-                };
-                return cx.spawn(|_| persistence::DB.save_workspace(serialized_workspace));
+        let location = if let Some(local_paths) = self.local_paths(cx) {
+            if !local_paths.paths().is_empty() {
+                Some(SerializedWorkspaceLocation::Local(local_paths))
+            } else {
+                None
             }
+        } else if let Some(remote_project_id) = self.project().read(cx).remote_project_id() {
+            let store = remote_projects::Store::global(cx).read(cx);
+            maybe!({
+                let project = store.remote_project(remote_project_id)?;
+                let dev_server = store.dev_server(project.dev_server_id)?;
+
+                let remote_project = SerializedRemoteProject {
+                    id: remote_project_id,
+                    dev_server_name: dev_server.name.to_string(),
+                    path: project.path.to_string(),
+                };
+                Some(SerializedWorkspaceLocation::Remote(remote_project))
+            })
+        } else {
+            None
+        };
+
+        // don't save workspace state for the empty workspace.
+        if let Some(location) = location {
+            let center_group = build_serialized_pane_group(&self.center.root, cx);
+            let docks = build_serialized_docks(self, cx);
+            let serialized_workspace = SerializedWorkspace {
+                id: self.database_id,
+                location,
+                center_group,
+                bounds: Default::default(),
+                display: Default::default(),
+                docks,
+                fullscreen: cx.is_fullscreen(),
+                centered_layout: self.centered_layout,
+            };
+            return cx.spawn(|_| persistence::DB.save_workspace(serialized_workspace));
         }
         Task::ready(())
     }
@@ -4303,7 +4321,7 @@ pub fn activate_workspace_for_project(
     None
 }
 
-pub async fn last_opened_workspace_paths() -> Option<WorkspaceLocation> {
+pub async fn last_opened_workspace_paths() -> Option<LocalPaths> {
     DB.last_workspace().await.log_err().flatten()
 }
 
@@ -4410,7 +4428,6 @@ async fn join_channel_internal(
         if let Some((project, host)) = room.most_active_project(cx) {
             return Some(join_in_room_project(project, host, app_state.clone(), cx));
         }
-
         // if you are the first to join a channel, share your project
         if room.remote_participants().len() == 0 && !room.local_participant_is_guest() {
             if let Some(workspace) = requesting_window {
@@ -4419,7 +4436,7 @@ async fn join_channel_internal(
                         return None;
                     }
                     let project = workspace.project.read(cx);
-                    if project.is_local()
+                    if (project.is_local() || project.remote_project_id().is_some())
                         && project.visible_worktrees(cx).any(|tree| {
                             tree.read(cx)
                                 .root_entry()

crates/zed/Cargo.toml 🔗

@@ -71,6 +71,7 @@ project_panel.workspace = true
 project_symbols.workspace = true
 quick_action_bar.workspace = true
 recent_projects.workspace = true
+remote_projects.workspace = true
 release_channel.workspace = true
 rope.workspace = true
 search.workspace = true

crates/zed/src/main.rs 🔗

@@ -286,6 +286,7 @@ fn init_ui(args: Args) {
             ThemeRegistry::global(cx),
             cx,
         );
+        remote_projects::init(client.clone(), cx);
 
         load_user_themes_in_background(fs.clone(), cx);
         watch_themes(fs.clone(), cx);

script/zed-local 🔗

@@ -42,6 +42,7 @@ let instanceCount = 1;
 let isReleaseMode = false;
 let isTop = false;
 let othersOnStable = false;
+let isStateful = false;
 
 const args = process.argv.slice(2);
 while (args.length > 0) {
@@ -52,6 +53,8 @@ while (args.length > 0) {
     instanceCount = parseInt(digitMatch[1]);
   } else if (arg === "--release") {
     isReleaseMode = true;
+  } else if (arg == "--stateful") {
+    isStateful = true;
   } else if (arg === "--top") {
     isTop = true;
   } else if (arg === "--help") {
@@ -147,7 +150,7 @@ setTimeout(() => {
       env: {
         ZED_IMPERSONATE: users[i],
         ZED_WINDOW_POSITION: position,
-        ZED_STATELESS: "1",
+        ZED_STATELESS: isStateful && i == 0 ? "1" : "",
         ZED_ALWAYS_ACTIVE: "1",
         ZED_SERVER_URL: "http://localhost:3000",
         ZED_RPC_URL: "http://localhost:8080/rpc",