hosted projects (#8627)

Conrad Irwin created

- **Allow joining a hosted project**

You can't yet do anything in a hosted project, but you can join it and
look how empty it is.

Release Notes:

- N/A

Change summary

crates/call/src/room.rs                                                 |  13 
crates/channel/src/channel.rs                                           |   2 
crates/channel/src/channel_store.rs                                     |   7 
crates/client/src/user.rs                                               |   3 
crates/collab/migrations.sqlite/20221109000000_test_schema.sql          |   5 
crates/collab/migrations/20240227215556_hosted_projects_in_projects.sql |   3 
crates/collab/src/db.rs                                                 |   4 
crates/collab/src/db/queries/hosted_projects.rs                         |  42 
crates/collab/src/db/queries/projects.rs                                | 413 
crates/collab/src/db/queries/rooms.rs                                   |   4 
crates/collab/src/db/tables/project.rs                                  |   7 
crates/collab/src/rpc.rs                                                |  70 
crates/collab/src/tests/following_tests.rs                              |   2 
crates/collab/src/tests/integration_tests.rs                            |   2 
crates/collab_ui/src/collab_panel.rs                                    |  19 
crates/collab_ui/src/notifications/incoming_call_notification.rs        |   2 
crates/collab_ui/src/notifications/project_shared_notification.rs       |   2 
crates/project/src/project.rs                                           |  53 
crates/rpc/proto/zed.proto                                              |   9 
crates/rpc/src/proto.rs                                                 |   2 
crates/workspace/src/pane_group.rs                                      |   2 
crates/workspace/src/workspace.rs                                       |  59 
22 files changed, 506 insertions(+), 219 deletions(-)

Detailed changes

crates/call/src/room.rs 🔗

@@ -1182,19 +1182,10 @@ impl Room {
     ) -> Task<Result<Model<Project>>> {
         let client = self.client.clone();
         let user_store = self.user_store.clone();
-        let role = self.local_participant.role;
         cx.emit(Event::RemoteProjectJoined { project_id: id });
         cx.spawn(move |this, mut cx| async move {
-            let project = Project::remote(
-                id,
-                client,
-                user_store,
-                language_registry,
-                fs,
-                role,
-                cx.clone(),
-            )
-            .await?;
+            let project =
+                Project::remote(id, client, user_store, language_registry, fs, cx.clone()).await?;
 
             this.update(&mut cx, |this, cx| {
                 this.joined_projects.retain(|project| {

crates/channel/src/channel.rs 🔗

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

crates/channel/src/channel_store.rs 🔗

@@ -3,7 +3,9 @@ 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, Subscription, User, UserId, UserStore};
+use client::{
+    ChannelId, Client, ClientSettings, HostedProjectId, Subscription, User, UserId, UserStore,
+};
 use collections::{hash_map, HashMap, HashSet};
 use futures::{channel::mpsc, future::Shared, Future, FutureExt, StreamExt};
 use gpui::{
@@ -27,9 +29,6 @@ pub fn init(client: &Arc<Client>, user_store: Model<UserStore>, cx: &mut AppCont
     cx.set_global(GlobalChannelStore(channel_store));
 }
 
-#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
-pub struct HostedProjectId(pub u64);
-
 #[derive(Debug, Clone, Default)]
 struct NotesVersion {
     epoch: u64,

crates/client/src/user.rs 🔗

@@ -24,6 +24,9 @@ impl std::fmt::Display for ChannelId {
     }
 }
 
+#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
+pub struct HostedProjectId(pub u64);
+
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
 pub struct ParticipantIndex(pub u32);
 

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

@@ -46,10 +46,11 @@ CREATE UNIQUE INDEX "index_rooms_on_channel_id" ON "rooms" ("channel_id");
 CREATE TABLE "projects" (
     "id" INTEGER PRIMARY KEY AUTOINCREMENT,
     "room_id" INTEGER REFERENCES rooms (id) ON DELETE CASCADE NOT NULL,
-    "host_user_id" INTEGER REFERENCES users (id) NOT NULL,
+    "host_user_id" INTEGER REFERENCES users (id),
     "host_connection_id" INTEGER,
     "host_connection_server_id" INTEGER REFERENCES servers (id) ON DELETE CASCADE,
-    "unregistered" BOOLEAN NOT NULL DEFAULT FALSE
+    "unregistered" BOOLEAN NOT NULL DEFAULT FALSE,
+    "hosted_project_id" INTEGER REFERENCES hosted_projects (id)
 );
 CREATE INDEX "index_projects_on_host_connection_server_id" ON "projects" ("host_connection_server_id");
 CREATE INDEX "index_projects_on_host_connection_id_and_host_connection_server_id" ON "projects" ("host_connection_id", "host_connection_server_id");

crates/collab/src/db.rs 🔗

@@ -670,6 +670,8 @@ pub struct RefreshedChannelBuffer {
 }
 
 pub struct Project {
+    pub id: ProjectId,
+    pub role: ChannelRole,
     pub collaborators: Vec<ProjectCollaborator>,
     pub worktrees: BTreeMap<u64, Worktree>,
     pub language_servers: Vec<proto::LanguageServer>,
@@ -695,7 +697,7 @@ impl ProjectCollaborator {
 #[derive(Debug)]
 pub struct LeftProject {
     pub id: ProjectId,
-    pub host_user_id: UserId,
+    pub host_user_id: Option<UserId>,
     pub host_connection_id: Option<ConnectionId>,
     pub connection_ids: Vec<ConnectionId>,
 }

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

@@ -1,4 +1,4 @@
-use rpc::proto;
+use rpc::{proto, ErrorCode};
 
 use super::*;
 
@@ -39,4 +39,44 @@ impl Database {
             })
             .collect())
     }
+
+    pub async fn get_hosted_project(
+        &self,
+        hosted_project_id: HostedProjectId,
+        user_id: UserId,
+        tx: &DatabaseTransaction,
+    ) -> Result<(hosted_project::Model, ChannelRole)> {
+        let project = hosted_project::Entity::find_by_id(hosted_project_id)
+            .one(&*tx)
+            .await?
+            .ok_or_else(|| anyhow!(ErrorCode::NoSuchProject))?;
+        let channel = channel::Entity::find_by_id(project.channel_id)
+            .one(&*tx)
+            .await?
+            .ok_or_else(|| anyhow!(ErrorCode::NoSuchChannel))?;
+
+        let role = match project.visibility {
+            ChannelVisibility::Public => {
+                self.check_user_is_channel_participant(&channel, user_id, tx)
+                    .await?
+            }
+            ChannelVisibility::Members => {
+                self.check_user_is_channel_member(&channel, user_id, tx)
+                    .await?
+            }
+        };
+
+        Ok((project, role))
+    }
+
+    pub async fn is_hosted_project(&self, project_id: ProjectId) -> Result<bool> {
+        self.transaction(|tx| async move {
+            Ok(project::Entity::find_by_id(project_id)
+                .one(&*tx)
+                .await?
+                .map(|project| project.hosted_project_id.is_some())
+                .ok_or_else(|| anyhow!(ErrorCode::NoSuchProject))?)
+        })
+        .await
+    }
 }

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

@@ -57,13 +57,14 @@ impl Database {
             }
 
             let project = project::ActiveModel {
-                room_id: ActiveValue::set(participant.room_id),
-                host_user_id: ActiveValue::set(participant.user_id),
+                room_id: ActiveValue::set(Some(participant.room_id)),
+                host_user_id: ActiveValue::set(Some(participant.user_id)),
                 host_connection_id: ActiveValue::set(Some(connection.id as i32)),
                 host_connection_server_id: ActiveValue::set(Some(ServerId(
                     connection.owner_id as i32,
                 ))),
-                ..Default::default()
+                id: ActiveValue::NotSet,
+                hosted_project_id: ActiveValue::Set(None),
             }
             .insert(&*tx)
             .await?;
@@ -153,8 +154,12 @@ impl Database {
             self.update_project_worktrees(project.id, worktrees, &tx)
                 .await?;
 
+            let room_id = project
+                .room_id
+                .ok_or_else(|| anyhow!("project not in a room"))?;
+
             let guest_connection_ids = self.project_guest_connection_ids(project.id, &tx).await?;
-            let room = self.get_room(project.room_id, &tx).await?;
+            let room = self.get_room(room_id, &tx).await?;
             Ok((room, guest_connection_ids))
         })
         .await
@@ -504,8 +509,30 @@ impl Database {
         .await
     }
 
-    /// Adds the given connection to the specified project.
-    pub async fn join_project(
+    /// Adds the given connection to the specified hosted project
+    pub async fn join_hosted_project(
+        &self,
+        id: HostedProjectId,
+        user_id: UserId,
+        connection: ConnectionId,
+    ) -> Result<(Project, ReplicaId)> {
+        self.transaction(|tx| async move {
+            let (hosted_project, role) = self.get_hosted_project(id, user_id, &tx).await?;
+            let project = project::Entity::find()
+                .filter(project::Column::HostedProjectId.eq(hosted_project.id))
+                .one(&*tx)
+                .await?
+                .ok_or_else(|| anyhow!("hosted project is no longer shared"))?;
+
+            self.join_project_internal(project, user_id, connection, role, &tx)
+                .await
+        })
+        .await
+    }
+
+    /// Adds the given connection to the specified project
+    /// in the current room.
+    pub async fn join_project_in_room(
         &self,
         project_id: ProjectId,
         connection: ConnectionId,
@@ -532,180 +559,240 @@ impl Database {
                 .one(&*tx)
                 .await?
                 .ok_or_else(|| anyhow!("no such project"))?;
-            if project.room_id != participant.room_id {
+            if project.room_id != Some(participant.room_id) {
                 return Err(anyhow!("no such project"))?;
             }
+            self.join_project_internal(
+                project,
+                participant.user_id,
+                connection,
+                participant.role.unwrap_or(ChannelRole::Member),
+                &tx,
+            )
+            .await
+        })
+        .await
+    }
 
-            let mut collaborators = project
-                .find_related(project_collaborator::Entity)
-                .all(&*tx)
+    async fn join_project_internal(
+        &self,
+        project: project::Model,
+        user_id: UserId,
+        connection: ConnectionId,
+        role: ChannelRole,
+        tx: &DatabaseTransaction,
+    ) -> Result<(Project, ReplicaId)> {
+        let mut collaborators = project
+            .find_related(project_collaborator::Entity)
+            .all(&*tx)
+            .await?;
+        let replica_ids = collaborators
+            .iter()
+            .map(|c| c.replica_id)
+            .collect::<HashSet<_>>();
+        let mut replica_id = ReplicaId(1);
+        while replica_ids.contains(&replica_id) {
+            replica_id.0 += 1;
+        }
+        let new_collaborator = project_collaborator::ActiveModel {
+            project_id: ActiveValue::set(project.id),
+            connection_id: ActiveValue::set(connection.id as i32),
+            connection_server_id: ActiveValue::set(ServerId(connection.owner_id as i32)),
+            user_id: ActiveValue::set(user_id),
+            replica_id: ActiveValue::set(replica_id),
+            is_host: ActiveValue::set(false),
+            ..Default::default()
+        }
+        .insert(&*tx)
+        .await?;
+        collaborators.push(new_collaborator);
+
+        let db_worktrees = project.find_related(worktree::Entity).all(&*tx).await?;
+        let mut worktrees = db_worktrees
+            .into_iter()
+            .map(|db_worktree| {
+                (
+                    db_worktree.id as u64,
+                    Worktree {
+                        id: db_worktree.id as u64,
+                        abs_path: db_worktree.abs_path,
+                        root_name: db_worktree.root_name,
+                        visible: db_worktree.visible,
+                        entries: Default::default(),
+                        repository_entries: Default::default(),
+                        diagnostic_summaries: Default::default(),
+                        settings_files: Default::default(),
+                        scan_id: db_worktree.scan_id as u64,
+                        completed_scan_id: db_worktree.completed_scan_id as u64,
+                    },
+                )
+            })
+            .collect::<BTreeMap<_, _>>();
+
+        // Populate worktree entries.
+        {
+            let mut db_entries = worktree_entry::Entity::find()
+                .filter(
+                    Condition::all()
+                        .add(worktree_entry::Column::ProjectId.eq(project.id))
+                        .add(worktree_entry::Column::IsDeleted.eq(false)),
+                )
+                .stream(&*tx)
                 .await?;
-            let replica_ids = collaborators
-                .iter()
-                .map(|c| c.replica_id)
-                .collect::<HashSet<_>>();
-            let mut replica_id = ReplicaId(1);
-            while replica_ids.contains(&replica_id) {
-                replica_id.0 += 1;
-            }
-            let new_collaborator = project_collaborator::ActiveModel {
-                project_id: ActiveValue::set(project_id),
-                connection_id: ActiveValue::set(connection.id as i32),
-                connection_server_id: ActiveValue::set(ServerId(connection.owner_id as i32)),
-                user_id: ActiveValue::set(participant.user_id),
-                replica_id: ActiveValue::set(replica_id),
-                is_host: ActiveValue::set(false),
-                ..Default::default()
+            while let Some(db_entry) = db_entries.next().await {
+                let db_entry = db_entry?;
+                if let Some(worktree) = worktrees.get_mut(&(db_entry.worktree_id as u64)) {
+                    worktree.entries.push(proto::Entry {
+                        id: db_entry.id as u64,
+                        is_dir: db_entry.is_dir,
+                        path: db_entry.path,
+                        inode: db_entry.inode as u64,
+                        mtime: Some(proto::Timestamp {
+                            seconds: db_entry.mtime_seconds as u64,
+                            nanos: db_entry.mtime_nanos as u32,
+                        }),
+                        is_symlink: db_entry.is_symlink,
+                        is_ignored: db_entry.is_ignored,
+                        is_external: db_entry.is_external,
+                        git_status: db_entry.git_status.map(|status| status as i32),
+                    });
+                }
             }
-            .insert(&*tx)
-            .await?;
-            collaborators.push(new_collaborator);
+        }
 
-            let db_worktrees = project.find_related(worktree::Entity).all(&*tx).await?;
-            let mut worktrees = db_worktrees
-                .into_iter()
-                .map(|db_worktree| {
-                    (
-                        db_worktree.id as u64,
-                        Worktree {
-                            id: db_worktree.id as u64,
-                            abs_path: db_worktree.abs_path,
-                            root_name: db_worktree.root_name,
-                            visible: db_worktree.visible,
-                            entries: Default::default(),
-                            repository_entries: Default::default(),
-                            diagnostic_summaries: Default::default(),
-                            settings_files: Default::default(),
-                            scan_id: db_worktree.scan_id as u64,
-                            completed_scan_id: db_worktree.completed_scan_id as u64,
+        // Populate repository entries.
+        {
+            let mut db_repository_entries = worktree_repository::Entity::find()
+                .filter(
+                    Condition::all()
+                        .add(worktree_repository::Column::ProjectId.eq(project.id))
+                        .add(worktree_repository::Column::IsDeleted.eq(false)),
+                )
+                .stream(&*tx)
+                .await?;
+            while let Some(db_repository_entry) = db_repository_entries.next().await {
+                let db_repository_entry = db_repository_entry?;
+                if let Some(worktree) = worktrees.get_mut(&(db_repository_entry.worktree_id as u64))
+                {
+                    worktree.repository_entries.insert(
+                        db_repository_entry.work_directory_id as u64,
+                        proto::RepositoryEntry {
+                            work_directory_id: db_repository_entry.work_directory_id as u64,
+                            branch: db_repository_entry.branch,
                         },
-                    )
-                })
-                .collect::<BTreeMap<_, _>>();
-
-            // Populate worktree entries.
-            {
-                let mut db_entries = worktree_entry::Entity::find()
-                    .filter(
-                        Condition::all()
-                            .add(worktree_entry::Column::ProjectId.eq(project_id))
-                            .add(worktree_entry::Column::IsDeleted.eq(false)),
-                    )
-                    .stream(&*tx)
-                    .await?;
-                while let Some(db_entry) = db_entries.next().await {
-                    let db_entry = db_entry?;
-                    if let Some(worktree) = worktrees.get_mut(&(db_entry.worktree_id as u64)) {
-                        worktree.entries.push(proto::Entry {
-                            id: db_entry.id as u64,
-                            is_dir: db_entry.is_dir,
-                            path: db_entry.path,
-                            inode: db_entry.inode as u64,
-                            mtime: Some(proto::Timestamp {
-                                seconds: db_entry.mtime_seconds as u64,
-                                nanos: db_entry.mtime_nanos as u32,
-                            }),
-                            is_symlink: db_entry.is_symlink,
-                            is_ignored: db_entry.is_ignored,
-                            is_external: db_entry.is_external,
-                            git_status: db_entry.git_status.map(|status| status as i32),
-                        });
-                    }
+                    );
                 }
             }
+        }
 
-            // Populate repository entries.
-            {
-                let mut db_repository_entries = worktree_repository::Entity::find()
-                    .filter(
-                        Condition::all()
-                            .add(worktree_repository::Column::ProjectId.eq(project_id))
-                            .add(worktree_repository::Column::IsDeleted.eq(false)),
-                    )
-                    .stream(&*tx)
-                    .await?;
-                while let Some(db_repository_entry) = db_repository_entries.next().await {
-                    let db_repository_entry = db_repository_entry?;
-                    if let Some(worktree) =
-                        worktrees.get_mut(&(db_repository_entry.worktree_id as u64))
-                    {
-                        worktree.repository_entries.insert(
-                            db_repository_entry.work_directory_id as u64,
-                            proto::RepositoryEntry {
-                                work_directory_id: db_repository_entry.work_directory_id as u64,
-                                branch: db_repository_entry.branch,
-                            },
-                        );
-                    }
+        // Populate worktree diagnostic summaries.
+        {
+            let mut db_summaries = worktree_diagnostic_summary::Entity::find()
+                .filter(worktree_diagnostic_summary::Column::ProjectId.eq(project.id))
+                .stream(&*tx)
+                .await?;
+            while let Some(db_summary) = db_summaries.next().await {
+                let db_summary = db_summary?;
+                if let Some(worktree) = worktrees.get_mut(&(db_summary.worktree_id as u64)) {
+                    worktree
+                        .diagnostic_summaries
+                        .push(proto::DiagnosticSummary {
+                            path: db_summary.path,
+                            language_server_id: db_summary.language_server_id as u64,
+                            error_count: db_summary.error_count as u32,
+                            warning_count: db_summary.warning_count as u32,
+                        });
                 }
             }
+        }
 
-            // Populate worktree diagnostic summaries.
-            {
-                let mut db_summaries = worktree_diagnostic_summary::Entity::find()
-                    .filter(worktree_diagnostic_summary::Column::ProjectId.eq(project_id))
-                    .stream(&*tx)
-                    .await?;
-                while let Some(db_summary) = db_summaries.next().await {
-                    let db_summary = db_summary?;
-                    if let Some(worktree) = worktrees.get_mut(&(db_summary.worktree_id as u64)) {
-                        worktree
-                            .diagnostic_summaries
-                            .push(proto::DiagnosticSummary {
-                                path: db_summary.path,
-                                language_server_id: db_summary.language_server_id as u64,
-                                error_count: db_summary.error_count as u32,
-                                warning_count: db_summary.warning_count as u32,
-                            });
-                    }
+        // Populate worktree settings files
+        {
+            let mut db_settings_files = worktree_settings_file::Entity::find()
+                .filter(worktree_settings_file::Column::ProjectId.eq(project.id))
+                .stream(&*tx)
+                .await?;
+            while let Some(db_settings_file) = db_settings_files.next().await {
+                let db_settings_file = db_settings_file?;
+                if let Some(worktree) = worktrees.get_mut(&(db_settings_file.worktree_id as u64)) {
+                    worktree.settings_files.push(WorktreeSettingsFile {
+                        path: db_settings_file.path,
+                        content: db_settings_file.content,
+                    });
                 }
             }
+        }
 
-            // Populate worktree settings files
-            {
-                let mut db_settings_files = worktree_settings_file::Entity::find()
-                    .filter(worktree_settings_file::Column::ProjectId.eq(project_id))
-                    .stream(&*tx)
-                    .await?;
-                while let Some(db_settings_file) = db_settings_files.next().await {
-                    let db_settings_file = db_settings_file?;
-                    if let Some(worktree) =
-                        worktrees.get_mut(&(db_settings_file.worktree_id as u64))
-                    {
-                        worktree.settings_files.push(WorktreeSettingsFile {
-                            path: db_settings_file.path,
-                            content: db_settings_file.content,
-                        });
-                    }
-                }
+        // Populate language servers.
+        let language_servers = project
+            .find_related(language_server::Entity)
+            .all(&*tx)
+            .await?;
+
+        let project = Project {
+            id: project.id,
+            role,
+            collaborators: collaborators
+                .into_iter()
+                .map(|collaborator| ProjectCollaborator {
+                    connection_id: collaborator.connection(),
+                    user_id: collaborator.user_id,
+                    replica_id: collaborator.replica_id,
+                    is_host: collaborator.is_host,
+                })
+                .collect(),
+            worktrees,
+            language_servers: language_servers
+                .into_iter()
+                .map(|language_server| proto::LanguageServer {
+                    id: language_server.id as u64,
+                    name: language_server.name,
+                })
+                .collect(),
+        };
+        Ok((project, replica_id as ReplicaId))
+    }
+
+    pub async fn leave_hosted_project(
+        &self,
+        project_id: ProjectId,
+        connection: ConnectionId,
+    ) -> Result<LeftProject> {
+        self.transaction(|tx| async move {
+            let result = project_collaborator::Entity::delete_many()
+                .filter(
+                    Condition::all()
+                        .add(project_collaborator::Column::ProjectId.eq(project_id))
+                        .add(project_collaborator::Column::ConnectionId.eq(connection.id as i32))
+                        .add(
+                            project_collaborator::Column::ConnectionServerId
+                                .eq(connection.owner_id as i32),
+                        ),
+                )
+                .exec(&*tx)
+                .await?;
+            if result.rows_affected == 0 {
+                return Err(anyhow!("not in the project"))?;
             }
 
-            // Populate language servers.
-            let language_servers = project
-                .find_related(language_server::Entity)
+            let project = project::Entity::find_by_id(project_id)
+                .one(&*tx)
+                .await?
+                .ok_or_else(|| anyhow!("no such project"))?;
+            let collaborators = project
+                .find_related(project_collaborator::Entity)
                 .all(&*tx)
                 .await?;
-
-            let project = Project {
-                collaborators: collaborators
-                    .into_iter()
-                    .map(|collaborator| ProjectCollaborator {
-                        connection_id: collaborator.connection(),
-                        user_id: collaborator.user_id,
-                        replica_id: collaborator.replica_id,
-                        is_host: collaborator.is_host,
-                    })
-                    .collect(),
-                worktrees,
-                language_servers: language_servers
-                    .into_iter()
-                    .map(|language_server| proto::LanguageServer {
-                        id: language_server.id as u64,
-                        name: language_server.name,
-                    })
-                    .collect(),
-            };
-            Ok((project, replica_id as ReplicaId))
+            let connection_ids = collaborators
+                .into_iter()
+                .map(|collaborator| collaborator.connection())
+                .collect();
+            Ok(LeftProject {
+                id: project.id,
+                connection_ids,
+                host_user_id: None,
+                host_connection_id: None,
+            })
         })
         .await
     }
@@ -772,7 +859,7 @@ impl Database {
                 .exec(&*tx)
                 .await?;
 
-            let room = self.get_room(project.room_id, &tx).await?;
+            let room = self.get_room(room_id, &tx).await?;
             let left_project = LeftProject {
                 id: project_id,
                 host_user_id: project.host_user_id,
@@ -996,7 +1083,9 @@ impl Database {
                 .one(&*tx)
                 .await?
                 .ok_or_else(|| anyhow!("project {} not found", project_id))?;
-            Ok(project.room_id)
+            Ok(project
+                .room_id
+                .ok_or_else(|| anyhow!("project not in room"))?)
         })
         .await
     }

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

@@ -491,7 +491,7 @@ impl Database {
                     .one(&*tx)
                     .await?
                     .ok_or_else(|| anyhow!("project does not exist"))?;
-                if project.host_user_id != user_id {
+                if project.host_user_id != Some(user_id) {
                     return Err(anyhow!("no such project"))?;
                 }
 
@@ -851,7 +851,7 @@ impl Database {
                     }
 
                     if collaborator.is_host {
-                        left_project.host_user_id = collaborator.user_id;
+                        left_project.host_user_id = Some(collaborator.user_id);
                         left_project.host_connection_id = Some(collaborator_connection_id);
                     }
                 }

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

@@ -1,4 +1,4 @@
-use crate::db::{ProjectId, Result, RoomId, ServerId, UserId};
+use crate::db::{HostedProjectId, ProjectId, Result, RoomId, ServerId, UserId};
 use anyhow::anyhow;
 use rpc::ConnectionId;
 use sea_orm::entity::prelude::*;
@@ -8,10 +8,11 @@ use sea_orm::entity::prelude::*;
 pub struct Model {
     #[sea_orm(primary_key)]
     pub id: ProjectId,
-    pub room_id: RoomId,
-    pub host_user_id: UserId,
+    pub room_id: Option<RoomId>,
+    pub host_user_id: Option<UserId>,
     pub host_connection_id: Option<i32>,
     pub host_connection_server_id: Option<ServerId>,
+    pub hosted_project_id: Option<HostedProjectId>,
 }
 
 impl Model {

crates/collab/src/rpc.rs 🔗

@@ -4,8 +4,9 @@ use crate::{
     auth::{self, Impersonator},
     db::{
         self, BufferId, ChannelId, ChannelRole, ChannelsForUser, CreatedChannelMessage, Database,
-        InviteMemberResult, MembershipUpdated, MessageId, NotificationId, ProjectId,
-        RemoveChannelMemberResult, RespondToChannelInvite, RoomId, ServerId, User, UserId,
+        HostedProjectId, InviteMemberResult, MembershipUpdated, MessageId, NotificationId, Project,
+        ProjectId, RemoveChannelMemberResult, ReplicaId, RespondToChannelInvite, RoomId, ServerId,
+        User, UserId,
     },
     executor::Executor,
     AppState, Error, Result,
@@ -197,6 +198,7 @@ impl Server {
             .add_request_handler(share_project)
             .add_message_handler(unshare_project)
             .add_request_handler(join_project)
+            .add_request_handler(join_hosted_project)
             .add_message_handler(leave_project)
             .add_request_handler(update_project)
             .add_request_handler(update_worktree)
@@ -1584,22 +1586,46 @@ async fn join_project(
     session: Session,
 ) -> Result<()> {
     let project_id = ProjectId::from_proto(request.project_id);
-    let guest_user_id = session.user_id;
 
     tracing::info!(%project_id, "join project");
 
     let (project, replica_id) = &mut *session
         .db()
         .await
-        .join_project(project_id, session.connection_id)
+        .join_project_in_room(project_id, session.connection_id)
         .await?;
 
+    join_project_internal(response, session, project, replica_id)
+}
+
+trait JoinProjectInternalResponse {
+    fn send(self, result: proto::JoinProjectResponse) -> Result<()>;
+}
+impl JoinProjectInternalResponse for Response<proto::JoinProject> {
+    fn send(self, result: proto::JoinProjectResponse) -> Result<()> {
+        Response::<proto::JoinProject>::send(self, result)
+    }
+}
+impl JoinProjectInternalResponse for Response<proto::JoinHostedProject> {
+    fn send(self, result: proto::JoinProjectResponse) -> Result<()> {
+        Response::<proto::JoinHostedProject>::send(self, result)
+    }
+}
+
+fn join_project_internal(
+    response: impl JoinProjectInternalResponse,
+    session: Session,
+    project: &mut Project,
+    replica_id: &ReplicaId,
+) -> Result<()> {
     let collaborators = project
         .collaborators
         .iter()
         .filter(|collaborator| collaborator.connection_id != session.connection_id)
         .map(|collaborator| collaborator.to_proto())
         .collect::<Vec<_>>();
+    let project_id = project.id;
+    let guest_user_id = session.user_id;
 
     let worktrees = project
         .worktrees
@@ -1631,10 +1657,12 @@ async fn join_project(
 
     // First, we send the metadata associated with each worktree.
     response.send(proto::JoinProjectResponse {
+        project_id: project.id.0 as u64,
         worktrees: worktrees.clone(),
         replica_id: replica_id.0 as u32,
         collaborators: collaborators.clone(),
         language_servers: project.language_servers.clone(),
+        role: project.role.into(), // todo
     })?;
 
     for (worktree_id, worktree) in mem::take(&mut project.worktrees) {
@@ -1707,15 +1735,17 @@ async fn join_project(
 async fn leave_project(request: proto::LeaveProject, session: Session) -> Result<()> {
     let sender_id = session.connection_id;
     let project_id = ProjectId::from_proto(request.project_id);
+    let db = session.db().await;
+    if db.is_hosted_project(project_id).await? {
+        let project = db.leave_hosted_project(project_id, sender_id).await?;
+        project_left(&project, &session);
+        return Ok(());
+    }
 
-    let (room, project) = &*session
-        .db()
-        .await
-        .leave_project(project_id, sender_id)
-        .await?;
+    let (room, project) = &*db.leave_project(project_id, sender_id).await?;
     tracing::info!(
         %project_id,
-        host_user_id = %project.host_user_id,
+        host_user_id = ?project.host_user_id,
         host_connection_id = ?project.host_connection_id,
         "leave project"
     );
@@ -1726,6 +1756,24 @@ async fn leave_project(request: proto::LeaveProject, session: Session) -> Result
     Ok(())
 }
 
+async fn join_hosted_project(
+    request: proto::JoinHostedProject,
+    response: Response<proto::JoinHostedProject>,
+    session: Session,
+) -> Result<()> {
+    let (mut project, replica_id) = session
+        .db()
+        .await
+        .join_hosted_project(
+            HostedProjectId(request.id as i32),
+            session.user_id,
+            session.connection_id,
+        )
+        .await?;
+
+    join_project_internal(response, session, &mut project, &replica_id)
+}
+
 /// Updates other participants with changes to the project
 async fn update_project(
     request: proto::UpdateProject,
@@ -3624,7 +3672,7 @@ async fn leave_channel_buffers_for_session(session: &Session) -> Result<()> {
 
 fn project_left(project: &db::LeftProject, session: &Session) {
     for connection_id in &project.connection_ids {
-        if project.host_user_id == session.user_id {
+        if project.host_user_id == Some(session.user_id) {
             session
                 .peer
                 .send(

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

@@ -1534,7 +1534,7 @@ async fn test_following_across_workspaces(cx_a: &mut TestAppContext, cx_b: &mut
     executor.run_until_parked();
     assert_eq!(visible_push_notifications(cx_a).len(), 1);
     cx_a.update(|cx| {
-        workspace::join_remote_project(
+        workspace::join_in_room_project(
             project_b_id,
             client_b.user_id().unwrap(),
             client_a.app_state.clone(),

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

@@ -22,7 +22,6 @@ use project::{
     search::SearchQuery, DiagnosticSummary, FormatTrigger, HoverBlockKind, Project, ProjectPath,
 };
 use rand::prelude::*;
-use rpc::proto::ChannelRole;
 use serde_json::json;
 use settings::SettingsStore;
 use std::{
@@ -3742,7 +3741,6 @@ async fn test_leaving_project(
             client_b.user_store().clone(),
             client_b.language_registry().clone(),
             FakeFs::new(cx.background_executor().clone()),
-            ChannelRole::Member,
             cx,
         )
     })

crates/collab_ui/src/collab_panel.rs 🔗

@@ -7,8 +7,8 @@ use crate::{
     CollaborationPanelSettings,
 };
 use call::ActiveCall;
-use channel::{Channel, ChannelEvent, ChannelStore, HostedProjectId};
-use client::{ChannelId, Client, Contact, User, UserStore};
+use channel::{Channel, ChannelEvent, ChannelStore};
+use client::{ChannelId, Client, Contact, HostedProjectId, User, UserStore};
 use contact_finder::ContactFinder;
 use db::kvp::KEY_VALUE_STORE;
 use editor::{Editor, EditorElement, EditorStyle};
@@ -911,7 +911,7 @@ impl CollabPanel {
                 this.workspace
                     .update(cx, |workspace, cx| {
                         let app_state = workspace.app_state().clone();
-                        workspace::join_remote_project(project_id, host_user_id, app_state, cx)
+                        workspace::join_in_room_project(project_id, host_user_id, app_state, cx)
                             .detach_and_prompt_err("Failed to join project", cx, |_, _| None);
                     })
                     .ok();
@@ -1047,8 +1047,15 @@ impl CollabPanel {
         .indent_level(2)
         .indent_step_size(px(20.))
         .selected(is_selected)
-        .on_click(cx.listener(move |_this, _, _cx| {
-            // todo()
+        .on_click(cx.listener(move |this, _, cx| {
+            if let Some(workspace) = this.workspace.upgrade() {
+                let app_state = workspace.read(cx).app_state().clone();
+                workspace::join_hosted_project(id, app_state, cx).detach_and_prompt_err(
+                    "Failed to open project",
+                    cx,
+                    |_, _| None,
+                )
+            }
         }))
         .start_slot(
             h_flex()
@@ -1461,7 +1468,7 @@ impl CollabPanel {
                     } => {
                         if let Some(workspace) = self.workspace.upgrade() {
                             let app_state = workspace.read(cx).app_state().clone();
-                            workspace::join_remote_project(
+                            workspace::join_in_room_project(
                                 *project_id,
                                 *host_user_id,
                                 app_state,

crates/collab_ui/src/notifications/incoming_call_notification.rs 🔗

@@ -82,7 +82,7 @@ impl IncomingCallNotificationState {
                 if let Some(project_id) = initial_project_id {
                     cx.update(|cx| {
                         if let Some(app_state) = app_state.upgrade() {
-                            workspace::join_remote_project(
+                            workspace::join_in_room_project(
                                 project_id,
                                 caller_user_id,
                                 app_state,

crates/collab_ui/src/notifications/project_shared_notification.rs 🔗

@@ -98,7 +98,7 @@ impl ProjectSharedNotification {
 
     fn join(&mut self, cx: &mut ViewContext<Self>) {
         if let Some(app_state) = self.app_state.upgrade() {
-            workspace::join_remote_project(self.project_id, self.owner.id, app_state, cx)
+            workspace::join_in_room_project(self.project_id, self.owner.id, app_state, cx)
                 .detach_and_log_err(cx);
         }
     }

crates/project/src/project.rs 🔗

@@ -11,7 +11,7 @@ mod project_tests;
 
 use anyhow::{anyhow, bail, Context as _, Result};
 use async_trait::async_trait;
-use client::{proto, Client, Collaborator, TypedEnvelope, UserStore};
+use client::{proto, Client, Collaborator, HostedProjectId, TypedEnvelope, UserStore};
 use clock::ReplicaId;
 use collections::{hash_map, BTreeMap, HashMap, HashSet, VecDeque};
 use copilot::Copilot;
@@ -167,6 +167,7 @@ pub struct Project {
     prettiers_per_worktree: HashMap<WorktreeId, HashSet<Option<PathBuf>>>,
     prettier_instances: HashMap<PathBuf, PrettierInstance>,
     tasks: Model<Inventory>,
+    hosted_project_id: Option<HostedProjectId>,
 }
 
 pub enum LanguageServerToQuery {
@@ -605,6 +606,7 @@ impl Project {
                 prettiers_per_worktree: HashMap::default(),
                 prettier_instances: HashMap::default(),
                 tasks,
+                hosted_project_id: None,
             }
         })
     }
@@ -615,17 +617,30 @@ impl Project {
         user_store: Model<UserStore>,
         languages: Arc<LanguageRegistry>,
         fs: Arc<dyn Fs>,
-        role: proto::ChannelRole,
-        mut cx: AsyncAppContext,
+        cx: AsyncAppContext,
     ) -> Result<Model<Self>> {
         client.authenticate_and_connect(true, &cx).await?;
 
-        let subscription = client.subscribe_to_entity(remote_id)?;
         let response = client
             .request_envelope(proto::JoinProject {
                 project_id: remote_id,
             })
             .await?;
+        Self::from_join_project_response(response, None, client, user_store, languages, fs, cx)
+            .await
+    }
+    async fn from_join_project_response(
+        response: TypedEnvelope<proto::JoinProjectResponse>,
+        hosted_project_id: Option<HostedProjectId>,
+        client: Arc<Client>,
+        user_store: Model<UserStore>,
+        languages: Arc<LanguageRegistry>,
+        fs: Arc<dyn Fs>,
+        mut cx: AsyncAppContext,
+    ) -> Result<Model<Self>> {
+        let remote_id = response.payload.project_id;
+        let role = response.payload.role();
+        let subscription = client.subscribe_to_entity(remote_id)?;
         let this = cx.new_model(|cx| {
             let replica_id = response.payload.replica_id as ReplicaId;
             let tasks = Inventory::new(cx);
@@ -714,6 +729,7 @@ impl Project {
                 prettiers_per_worktree: HashMap::default(),
                 prettier_instances: HashMap::default(),
                 tasks,
+                hosted_project_id,
             };
             this.set_role(role, cx);
             for worktree in worktrees {
@@ -742,6 +758,31 @@ impl Project {
         Ok(this)
     }
 
+    pub async fn hosted(
+        hosted_project_id: HostedProjectId,
+        user_store: Model<UserStore>,
+        client: Arc<Client>,
+        languages: Arc<LanguageRegistry>,
+        fs: Arc<dyn Fs>,
+        cx: AsyncAppContext,
+    ) -> Result<Model<Self>> {
+        let response = client
+            .request_envelope(proto::JoinHostedProject {
+                id: hosted_project_id.0,
+            })
+            .await?;
+        Self::from_join_project_response(
+            response,
+            Some(hosted_project_id),
+            client,
+            user_store,
+            languages,
+            fs,
+            cx,
+        )
+        .await
+    }
+
     fn release(&mut self, cx: &mut AppContext) {
         match &self.client_state {
             ProjectClientState::Local => {}
@@ -987,6 +1028,10 @@ impl Project {
         }
     }
 
+    pub fn hosted_project_id(&self) -> Option<HostedProjectId> {
+        self.hosted_project_id
+    }
+
     pub fn replica_id(&self) -> ReplicaId {
         match self.client_state {
             ProjectClientState::Remote { replica_id, .. } => replica_id,

crates/rpc/proto/zed.proto 🔗

@@ -196,6 +196,8 @@ message Envelope {
 
         GetImplementation get_implementation = 162;
         GetImplementationResponse get_implementation_response = 163;
+
+        JoinHostedProject join_hosted_project = 164;
     }
 
     reserved 158 to 161;
@@ -230,6 +232,7 @@ enum ErrorCode {
     CircularNesting = 10;
     WrongMoveTarget = 11;
     UnsharedItem = 12;
+    NoSuchProject = 13;
     reserved 6;
 }
 
@@ -404,11 +407,17 @@ message JoinProject {
     uint64 project_id = 1;
 }
 
+message JoinHostedProject {
+    uint64 id = 1;
+}
+
 message JoinProjectResponse {
+    uint64 project_id = 5;
     uint32 replica_id = 1;
     repeated WorktreeMetadata worktrees = 2;
     repeated Collaborator collaborators = 3;
     repeated LanguageServer language_servers = 4;
+    ChannelRole role = 6;
 }
 
 message LeaveProject {

crates/rpc/src/proto.rs 🔗

@@ -206,6 +206,7 @@ messages!(
     (JoinChannelChat, Foreground),
     (JoinChannelChatResponse, Foreground),
     (JoinProject, Foreground),
+    (JoinHostedProject, Foreground),
     (JoinProjectResponse, Foreground),
     (JoinRoom, Foreground),
     (JoinRoomResponse, Foreground),
@@ -329,6 +330,7 @@ request_messages!(
     (JoinChannel, JoinRoomResponse),
     (JoinChannelBuffer, JoinChannelBufferResponse),
     (JoinChannelChat, JoinChannelChatResponse),
+    (JoinHostedProject, JoinProjectResponse),
     (JoinProject, JoinProjectResponse),
     (JoinRoom, JoinRoomResponse),
     (LeaveChannelBuffer, Ack),

crates/workspace/src/pane_group.rs 🔗

@@ -268,7 +268,7 @@ impl Member {
                                         this.cursor_pointer().on_mouse_down(
                                             MouseButton::Left,
                                             cx.listener(move |this, _, cx| {
-                                                crate::join_remote_project(
+                                                crate::join_in_room_project(
                                                     leader_project_id,
                                                     leader_user_id,
                                                     this.app_state().clone(),

crates/workspace/src/workspace.rs 🔗

@@ -15,7 +15,7 @@ use anyhow::{anyhow, Context as _, Result};
 use call::{call_settings::CallSettings, ActiveCall};
 use client::{
     proto::{self, ErrorCode, PeerId},
-    ChannelId, Client, ErrorExt, Status, TypedEnvelope, UserStore,
+    ChannelId, Client, ErrorExt, HostedProjectId, Status, TypedEnvelope, UserStore,
 };
 use collections::{hash_map, HashMap, HashSet};
 use derive_more::{Deref, DerefMut};
@@ -2635,7 +2635,7 @@ impl Workspace {
         // if they are active in another project, follow there.
         if let Some(project_id) = other_project_id {
             let app_state = self.app_state.clone();
-            crate::join_remote_project(project_id, remote_participant.user.id, app_state, cx)
+            crate::join_in_room_project(project_id, remote_participant.user.id, app_state, cx)
                 .detach_and_log_err(cx);
         }
 
@@ -4158,7 +4158,7 @@ async fn join_channel_internal(
     if let Some(room) = open_room {
         let task = room.update(cx, |room, cx| {
             if let Some((project, host)) = room.most_active_project(cx) {
-                return Some(join_remote_project(project, host, app_state.clone(), cx));
+                return Some(join_in_room_project(project, host, app_state.clone(), cx));
             }
 
             None
@@ -4229,7 +4229,7 @@ async fn join_channel_internal(
 
     let task = room.update(cx, |room, cx| {
         if let Some((project, host)) = room.most_active_project(cx) {
-            return Some(join_remote_project(project, host, app_state.clone(), 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
@@ -4464,7 +4464,56 @@ pub fn create_and_open_local_file(
     })
 }
 
-pub fn join_remote_project(
+pub fn join_hosted_project(
+    hosted_project_id: HostedProjectId,
+    app_state: Arc<AppState>,
+    cx: &mut AppContext,
+) -> Task<Result<()>> {
+    cx.spawn(|mut cx| async move {
+        let existing_window = cx.update(|cx| {
+            cx.windows().into_iter().find_map(|window| {
+                let workspace = window.downcast::<Workspace>()?;
+                workspace
+                    .read(cx)
+                    .is_ok_and(|workspace| {
+                        workspace.project().read(cx).hosted_project_id() == Some(hosted_project_id)
+                    })
+                    .then(|| workspace)
+            })
+        })?;
+
+        let workspace = if let Some(existing_window) = existing_window {
+            existing_window
+        } else {
+            let project = Project::hosted(
+                hosted_project_id,
+                app_state.user_store.clone(),
+                app_state.client.clone(),
+                app_state.languages.clone(),
+                app_state.fs.clone(),
+                cx.clone(),
+            )
+            .await?;
+
+            let window_bounds_override = window_bounds_env_override(&cx);
+            cx.update(|cx| {
+                let options = (app_state.build_window_options)(window_bounds_override, None, cx);
+                cx.open_window(options, |cx| {
+                    cx.new_view(|cx| Workspace::new(0, project, app_state.clone(), cx))
+                })
+            })?
+        };
+
+        workspace.update(&mut cx, |_, cx| {
+            cx.activate(true);
+            cx.activate_window();
+        })?;
+
+        Ok(())
+    })
+}
+
+pub fn join_in_room_project(
     project_id: u64,
     follow_user_id: u64,
     app_state: Arc<AppState>,