Read-only access for channel guests (#3841)

Conrad Irwin created

Change summary

crates/assistant/src/assistant_panel.rs                                   |   6 
crates/call/src/participant.rs                                            |   2 
crates/call/src/room.rs                                                   |  58 
crates/channel/src/channel_buffer.rs                                      |   7 
crates/channel/src/channel_store.rs                                       |   9 
crates/collab/migrations.sqlite/20221109000000_test_schema.sql            |   3 
crates/collab/migrations/20240103025509_add_role_to_room_participants.sql |   1 
crates/collab/src/db/ids.rs                                               |   8 
crates/collab/src/db/queries/channels.rs                                  |  57 
crates/collab/src/db/queries/projects.rs                                  |   7 
crates/collab/src/db/queries/rooms.rs                                     |  40 
crates/collab/src/db/tables/room_participant.rs                           |   3 
crates/collab/src/tests.rs                                                |   1 
crates/collab/src/tests/channel_guest_tests.rs                            |  86 
crates/collab/src/tests/integration_tests.rs                              |  26 
crates/collab/src/tests/random_project_collaboration_tests.rs             |   4 
crates/collab/src/tests/randomized_test_helpers.rs                        |   2 
crates/collab_ui/src/channel_view.rs                                      |  15 
crates/collab_ui/src/collab_panel.rs                                      | 105 
crates/collab_ui/src/collab_titlebar_item.rs                              |  73 
crates/diagnostics/src/diagnostics.rs                                     |   7 
crates/editor/src/editor.rs                                               |  31 
crates/editor/src/editor_tests.rs                                         |  21 
crates/editor/src/element.rs                                              |   8 
crates/editor/src/git.rs                                                  |   3 
crates/editor/src/inlay_hint_cache.rs                                     |   7 
crates/editor/src/items.rs                                                |   3 
crates/editor/src/movement.rs                                             |   3 
crates/gpui/src/color.rs                                                  |   9 
crates/gpui/src/element.rs                                                |   5 
crates/language/src/buffer.rs                                             |  28 
crates/language/src/buffer_tests.rs                                       |  14 
crates/live_kit_server/src/token.rs                                       |   1 
crates/multi_buffer/src/multi_buffer.rs                                   |  39 
crates/project/src/project.rs                                             |  45 
crates/project/src/worktree.rs                                            |  12 
crates/project_panel/src/project_panel.rs                                 |  35 
crates/rpc/proto/zed.proto                                                |   1 
crates/search/src/buffer_search.rs                                        |   2 
crates/search/src/project_search.rs                                       |   6 
crates/theme/src/styles/players.rs                                        |   9 
crates/workspace/src/workspace.rs                                         |   6 
42 files changed, 618 insertions(+), 190 deletions(-)

Detailed changes

crates/assistant/src/assistant_panel.rs 🔗

@@ -2827,8 +2827,8 @@ impl InlineAssistant {
 
     fn handle_codegen_changed(&mut self, _: Model<Codegen>, cx: &mut ViewContext<Self>) {
         let is_read_only = !self.codegen.read(cx).idle();
-        self.prompt_editor.update(cx, |editor, _cx| {
-            let was_read_only = editor.read_only();
+        self.prompt_editor.update(cx, |editor, cx| {
+            let was_read_only = editor.read_only(cx);
             if was_read_only != is_read_only {
                 if is_read_only {
                     editor.set_read_only(true);
@@ -3063,7 +3063,7 @@ impl InlineAssistant {
     fn render_prompt_editor(&self, cx: &mut ViewContext<Self>) -> impl IntoElement {
         let settings = ThemeSettings::get_global(cx);
         let text_style = TextStyle {
-            color: if self.prompt_editor.read(cx).read_only() {
+            color: if self.prompt_editor.read(cx).read_only(cx) {
                 cx.theme().colors().text_disabled
             } else {
                 cx.theme().colors().text

crates/call/src/participant.rs 🔗

@@ -36,12 +36,14 @@ impl ParticipantLocation {
 pub struct LocalParticipant {
     pub projects: Vec<proto::ParticipantProject>,
     pub active_project: Option<WeakModel<Project>>,
+    pub role: proto::ChannelRole,
 }
 
 #[derive(Clone, Debug)]
 pub struct RemoteParticipant {
     pub user: Arc<User>,
     pub peer_id: proto::PeerId,
+    pub role: proto::ChannelRole,
     pub projects: Vec<proto::ParticipantProject>,
     pub location: ParticipantLocation,
     pub participant_index: ParticipantIndex,

crates/call/src/room.rs 🔗

@@ -247,14 +247,18 @@ impl Room {
             let response = client.request(proto::CreateRoom {}).await?;
             let room_proto = response.room.ok_or_else(|| anyhow!("invalid room"))?;
             let room = cx.new_model(|cx| {
-                Self::new(
+                let mut room = Self::new(
                     room_proto.id,
                     None,
                     response.live_kit_connection_info,
                     client,
                     user_store,
                     cx,
-                )
+                );
+                if let Some(participant) = room_proto.participants.first() {
+                    room.local_participant.role = participant.role()
+                }
+                room
             })?;
 
             let initial_project_id = if let Some(initial_project) = initial_project {
@@ -606,6 +610,16 @@ impl Room {
             .find(|p| p.peer_id == peer_id)
     }
 
+    pub fn role_for_user(&self, user_id: u64) -> Option<proto::ChannelRole> {
+        self.remote_participants
+            .get(&user_id)
+            .map(|participant| participant.role)
+    }
+
+    pub fn local_participant_is_admin(&self) -> bool {
+        self.local_participant.role == proto::ChannelRole::Admin
+    }
+
     pub fn pending_participants(&self) -> &[Arc<User>] {
         &self.pending_participants
     }
@@ -710,7 +724,20 @@ impl Room {
                 this.participant_user_ids.clear();
 
                 if let Some(participant) = local_participant {
+                    let role = participant.role();
                     this.local_participant.projects = participant.projects;
+                    if this.local_participant.role != role {
+                        this.local_participant.role = role;
+
+                        this.joined_projects.retain(|project| {
+                            if let Some(project) = project.upgrade() {
+                                project.update(cx, |project, _| project.set_role(role));
+                                true
+                            } else {
+                                false
+                            }
+                        });
+                    }
                 } else {
                     this.local_participant.projects.clear();
                 }
@@ -766,6 +793,7 @@ impl Room {
                             });
                         }
 
+                        let role = participant.role();
                         let location = ParticipantLocation::from_proto(participant.location)
                             .unwrap_or(ParticipantLocation::External);
                         if let Some(remote_participant) =
@@ -774,8 +802,11 @@ impl Room {
                             remote_participant.peer_id = peer_id;
                             remote_participant.projects = participant.projects;
                             remote_participant.participant_index = participant_index;
-                            if location != remote_participant.location {
+                            if location != remote_participant.location
+                                || role != remote_participant.role
+                            {
                                 remote_participant.location = location;
+                                remote_participant.role = role;
                                 cx.emit(Event::ParticipantLocationChanged {
                                     participant_id: peer_id,
                                 });
@@ -789,6 +820,7 @@ impl Room {
                                     peer_id,
                                     projects: participant.projects,
                                     location,
+                                    role,
                                     muted: true,
                                     speaking: false,
                                     video_tracks: Default::default(),
@@ -1091,15 +1123,24 @@ 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, cx.clone()).await?;
+            let project = Project::remote(
+                id,
+                client,
+                user_store,
+                language_registry,
+                fs,
+                role,
+                cx.clone(),
+            )
+            .await?;
 
             this.update(&mut cx, |this, cx| {
                 this.joined_projects.retain(|project| {
                     if let Some(project) = project.upgrade() {
-                        !project.read(cx).is_read_only()
+                        !project.read(cx).is_disconnected()
                     } else {
                         false
                     }
@@ -1224,6 +1265,11 @@ impl Room {
             .unwrap_or(false)
     }
 
+    pub fn read_only(&self) -> bool {
+        !(self.local_participant().role == proto::ChannelRole::Member
+            || self.local_participant().role == proto::ChannelRole::Admin)
+    }
+
     pub fn is_speaking(&self) -> bool {
         self.live_kit
             .as_ref()

crates/channel/src/channel_buffer.rs 🔗

@@ -62,7 +62,12 @@ impl ChannelBuffer {
             .collect::<Result<Vec<_>, _>>()?;
 
         let buffer = cx.new_model(|_| {
-            language::Buffer::remote(response.buffer_id, response.replica_id as u16, base_text)
+            language::Buffer::remote(
+                response.buffer_id,
+                response.replica_id as u16,
+                channel.channel_buffer_capability(),
+                base_text,
+            )
         })?;
         buffer.update(&mut cx, |buffer, cx| buffer.apply_ops(operations, cx))??;
 

crates/channel/src/channel_store.rs 🔗

@@ -11,6 +11,7 @@ use gpui::{
     AppContext, AsyncAppContext, Context, EventEmitter, Model, ModelContext, SharedString, Task,
     WeakModel,
 };
+use language::Capability;
 use rpc::{
     proto::{self, ChannelVisibility},
     TypedEnvelope,
@@ -74,8 +75,12 @@ impl Channel {
         slug.trim_matches(|c| c == '-').to_string()
     }
 
-    pub fn can_edit_notes(&self) -> bool {
-        self.role == proto::ChannelRole::Member || self.role == proto::ChannelRole::Admin
+    pub fn channel_buffer_capability(&self) -> Capability {
+        if self.role == proto::ChannelRole::Member || self.role == proto::ChannelRole::Admin {
+            Capability::ReadWrite
+        } else {
+            Capability::ReadOnly
+        }
     }
 }
 

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

@@ -161,7 +161,8 @@ CREATE TABLE "room_participants" (
     "calling_user_id" INTEGER NOT NULL REFERENCES users (id),
     "calling_connection_id" INTEGER NOT NULL,
     "calling_connection_server_id" INTEGER REFERENCES servers (id) ON DELETE SET NULL,
-    "participant_index" INTEGER
+    "participant_index" INTEGER,
+    "role" TEXT
 );
 CREATE UNIQUE INDEX "index_room_participants_on_user_id" ON "room_participants" ("user_id");
 CREATE INDEX "index_room_participants_on_room_id" ON "room_participants" ("room_id");

crates/collab/src/db/ids.rs 🔗

@@ -132,6 +132,14 @@ impl ChannelRole {
             Admin | Member | Banned => false,
         }
     }
+
+    pub fn can_share_projects(&self) -> bool {
+        use ChannelRole::*;
+        match self {
+            Admin | Member => true,
+            Guest | Banned => false,
+        }
+    }
 }
 
 impl From<proto::ChannelRole> for ChannelRole {

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

@@ -132,48 +132,49 @@ impl Database {
                     debug_assert!(
                         self.channel_role_for_user(&channel, user_id, &*tx).await? == role
                     );
-                }
-            }
-
-            if channel.visibility == ChannelVisibility::Public {
-                role = Some(ChannelRole::Guest);
-                let channel_to_join = self
-                    .public_ancestors_including_self(&channel, &*tx)
-                    .await?
-                    .first()
-                    .cloned()
-                    .unwrap_or(channel.clone());
-
-                channel_member::Entity::insert(channel_member::ActiveModel {
-                    id: ActiveValue::NotSet,
-                    channel_id: ActiveValue::Set(channel_to_join.id),
-                    user_id: ActiveValue::Set(user_id),
-                    accepted: ActiveValue::Set(true),
-                    role: ActiveValue::Set(ChannelRole::Guest),
-                })
-                .exec(&*tx)
-                .await?;
+                } else if channel.visibility == ChannelVisibility::Public {
+                    role = Some(ChannelRole::Guest);
+                    let channel_to_join = self
+                        .public_ancestors_including_self(&channel, &*tx)
+                        .await?
+                        .first()
+                        .cloned()
+                        .unwrap_or(channel.clone());
+
+                    channel_member::Entity::insert(channel_member::ActiveModel {
+                        id: ActiveValue::NotSet,
+                        channel_id: ActiveValue::Set(channel_to_join.id),
+                        user_id: ActiveValue::Set(user_id),
+                        accepted: ActiveValue::Set(true),
+                        role: ActiveValue::Set(ChannelRole::Guest),
+                    })
+                    .exec(&*tx)
+                    .await?;
 
-                accept_invite_result = Some(
-                    self.calculate_membership_updated(&channel_to_join, user_id, &*tx)
-                        .await?,
-                );
+                    accept_invite_result = Some(
+                        self.calculate_membership_updated(&channel_to_join, user_id, &*tx)
+                            .await?,
+                    );
 
-                debug_assert!(self.channel_role_for_user(&channel, user_id, &*tx).await? == role);
+                    debug_assert!(
+                        self.channel_role_for_user(&channel, user_id, &*tx).await? == role
+                    );
+                }
             }
 
             if role.is_none() || role == Some(ChannelRole::Banned) {
                 Err(anyhow!("not allowed"))?
             }
+            let role = role.unwrap();
 
             let live_kit_room = format!("channel-{}", nanoid::nanoid!(30));
             let room_id = self
                 .get_or_create_channel_room(channel_id, &live_kit_room, environment, &*tx)
                 .await?;
 
-            self.join_channel_room_internal(room_id, user_id, connection, &*tx)
+            self.join_channel_room_internal(room_id, user_id, connection, role, &*tx)
                 .await
-                .map(|jr| (jr, accept_invite_result, role.unwrap()))
+                .map(|jr| (jr, accept_invite_result, role))
         })
         .await
     }

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

@@ -46,6 +46,13 @@ impl Database {
             if participant.room_id != room_id {
                 return Err(anyhow!("shared project on unexpected room"))?;
             }
+            if !participant
+                .role
+                .unwrap_or(ChannelRole::Member)
+                .can_share_projects()
+            {
+                return Err(anyhow!("guests cannot share projects"))?;
+            }
 
             let project = project::ActiveModel {
                 room_id: ActiveValue::set(participant.room_id),

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

@@ -131,7 +131,12 @@ impl Database {
                     connection.owner_id as i32,
                 ))),
                 participant_index: ActiveValue::set(Some(0)),
-                ..Default::default()
+                role: ActiveValue::set(Some(ChannelRole::Admin)),
+
+                id: ActiveValue::NotSet,
+                location_kind: ActiveValue::NotSet,
+                location_project_id: ActiveValue::NotSet,
+                initial_project_id: ActiveValue::NotSet,
             }
             .insert(&*tx)
             .await?;
@@ -151,6 +156,22 @@ impl Database {
         initial_project_id: Option<ProjectId>,
     ) -> Result<RoomGuard<(proto::Room, proto::IncomingCall)>> {
         self.room_transaction(room_id, |tx| async move {
+            let caller = room_participant::Entity::find()
+                .filter(
+                    room_participant::Column::UserId
+                        .eq(calling_user_id)
+                        .and(room_participant::Column::RoomId.eq(room_id)),
+                )
+                .one(&*tx)
+                .await?
+                .ok_or_else(|| anyhow!("user is not in the room"))?;
+
+            let called_user_role = match caller.role.unwrap_or(ChannelRole::Member) {
+                ChannelRole::Admin | ChannelRole::Member => ChannelRole::Member,
+                ChannelRole::Guest => ChannelRole::Guest,
+                ChannelRole::Banned => return Err(anyhow!("banned users cannot invite").into()),
+            };
+
             room_participant::ActiveModel {
                 room_id: ActiveValue::set(room_id),
                 user_id: ActiveValue::set(called_user_id),
@@ -162,7 +183,13 @@ impl Database {
                     calling_connection.owner_id as i32,
                 ))),
                 initial_project_id: ActiveValue::set(initial_project_id),
-                ..Default::default()
+                role: ActiveValue::set(Some(called_user_role)),
+
+                id: ActiveValue::NotSet,
+                answering_connection_id: ActiveValue::NotSet,
+                answering_connection_server_id: ActiveValue::NotSet,
+                location_kind: ActiveValue::NotSet,
+                location_project_id: ActiveValue::NotSet,
             }
             .insert(&*tx)
             .await?;
@@ -384,6 +411,7 @@ impl Database {
         room_id: RoomId,
         user_id: UserId,
         connection: ConnectionId,
+        role: ChannelRole,
         tx: &DatabaseTransaction,
     ) -> Result<JoinRoom> {
         let participant_index = self
@@ -404,7 +432,11 @@ impl Database {
                 connection.owner_id as i32,
             ))),
             participant_index: ActiveValue::Set(Some(participant_index)),
-            ..Default::default()
+            role: ActiveValue::set(Some(role)),
+            id: ActiveValue::NotSet,
+            location_kind: ActiveValue::NotSet,
+            location_project_id: ActiveValue::NotSet,
+            initial_project_id: ActiveValue::NotSet,
         }])
         .on_conflict(
             OnConflict::columns([room_participant::Column::UserId])
@@ -413,6 +445,7 @@ impl Database {
                     room_participant::Column::AnsweringConnectionServerId,
                     room_participant::Column::AnsweringConnectionLost,
                     room_participant::Column::ParticipantIndex,
+                    room_participant::Column::Role,
                 ])
                 .to_owned(),
         )
@@ -1126,6 +1159,7 @@ impl Database {
                         projects: Default::default(),
                         location: Some(proto::ParticipantLocation { variant: location }),
                         participant_index: participant_index as u32,
+                        role: db_participant.role.unwrap_or(ChannelRole::Member).into(),
                     },
                 );
             } else {

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

@@ -1,4 +1,4 @@
-use crate::db::{ProjectId, RoomId, RoomParticipantId, ServerId, UserId};
+use crate::db::{ChannelRole, ProjectId, RoomId, RoomParticipantId, ServerId, UserId};
 use rpc::ConnectionId;
 use sea_orm::entity::prelude::*;
 
@@ -19,6 +19,7 @@ pub struct Model {
     pub calling_connection_id: i32,
     pub calling_connection_server_id: Option<ServerId>,
     pub participant_index: Option<i32>,
+    pub role: Option<ChannelRole>,
 }
 
 impl Model {

crates/collab/src/tests.rs 🔗

@@ -2,6 +2,7 @@ use call::Room;
 use gpui::{Model, TestAppContext};
 
 mod channel_buffer_tests;
+mod channel_guest_tests;
 mod channel_message_tests;
 mod channel_tests;
 mod editor_tests;

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

@@ -0,0 +1,86 @@
+use crate::tests::TestServer;
+use call::ActiveCall;
+use gpui::{BackgroundExecutor, TestAppContext, VisualTestContext};
+use rpc::proto;
+use workspace::Workspace;
+
+#[gpui::test]
+async fn test_channel_guests(
+    executor: BackgroundExecutor,
+    cx_a: &mut TestAppContext,
+    cx_b: &mut TestAppContext,
+) {
+    let mut server = TestServer::start(executor.clone()).await;
+    let client_a = server.create_client(cx_a, "user_a").await;
+    let client_b = server.create_client(cx_b, "user_b").await;
+
+    let channel_id = server
+        .make_channel("the-channel", None, (&client_a, cx_a), &mut [])
+        .await;
+
+    client_a
+        .channel_store()
+        .update(cx_a, |channel_store, cx| {
+            channel_store.set_channel_visibility(channel_id, proto::ChannelVisibility::Public, cx)
+        })
+        .await
+        .unwrap();
+
+    client_a
+        .fs()
+        .insert_tree(
+            "/a",
+            serde_json::json!({
+                "a.txt": "a-contents",
+            }),
+        )
+        .await;
+
+    let active_call_a = cx_a.read(ActiveCall::global);
+
+    // Client A shares a project in the channel
+    active_call_a
+        .update(cx_a, |call, cx| call.join_channel(channel_id, cx))
+        .await
+        .unwrap();
+    let (project_a, _) = client_a.build_local_project("/a", cx_a).await;
+    let project_id = active_call_a
+        .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
+        .await
+        .unwrap();
+    cx_a.executor().run_until_parked();
+
+    // Client B joins channel A as a guest
+    cx_b.update(|cx| workspace::join_channel(channel_id, client_b.app_state.clone(), None, cx))
+        .await
+        .unwrap();
+
+    // b should be following a in the shared project.
+    // B is a guest,
+    cx_a.executor().run_until_parked();
+
+    // todo!() the test window does not call activation handlers
+    // correctly yet, so this API does not work.
+    // let project_b = active_call_b.read_with(cx_b, |call, _| {
+    //     call.location()
+    //         .unwrap()
+    //         .upgrade()
+    //         .expect("should not be weak")
+    // });
+
+    let window_b = cx_b.update(|cx| cx.active_window().unwrap());
+    let cx_b = &mut VisualTestContext::from_window(window_b, cx_b);
+
+    let workspace_b = window_b
+        .downcast::<Workspace>()
+        .unwrap()
+        .root_view(cx_b)
+        .unwrap();
+    let project_b = workspace_b.update(cx_b, |workspace, _| workspace.project().clone());
+
+    assert_eq!(
+        project_b.read_with(cx_b, |project, _| project.remote_id()),
+        Some(project_id),
+    );
+    assert!(project_b.read_with(cx_b, |project, _| project.is_read_only()))
+}

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

@@ -19,6 +19,7 @@ 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::{
@@ -1380,7 +1381,7 @@ async fn test_unshare_project(
         .unwrap();
     executor.run_until_parked();
 
-    assert!(project_b.read_with(cx_b, |project, _| project.is_read_only()));
+    assert!(project_b.read_with(cx_b, |project, _| project.is_disconnected()));
 
     // Client C opens the project.
     let project_c = client_c.build_remote_project(project_id, cx_c).await;
@@ -1393,7 +1394,7 @@ async fn test_unshare_project(
 
     assert!(worktree_a.read_with(cx_a, |tree, _| !tree.as_local().unwrap().is_shared()));
 
-    assert!(project_c.read_with(cx_c, |project, _| project.is_read_only()));
+    assert!(project_c.read_with(cx_c, |project, _| project.is_disconnected()));
 
     // Client C can open the project again after client A re-shares.
     let project_id = active_call_a
@@ -1419,7 +1420,7 @@ async fn test_unshare_project(
     project_a.read_with(cx_a, |project, _| assert!(!project.is_shared()));
 
     project_c2.read_with(cx_c, |project, _| {
-        assert!(project.is_read_only());
+        assert!(project.is_disconnected());
         assert!(project.collaborators().is_empty());
     });
 }
@@ -1551,7 +1552,7 @@ async fn test_project_reconnect(
     });
 
     project_b1.read_with(cx_b, |project, _| {
-        assert!(!project.is_read_only());
+        assert!(!project.is_disconnected());
         assert_eq!(project.collaborators().len(), 1);
     });
 
@@ -1653,7 +1654,7 @@ async fn test_project_reconnect(
     });
 
     project_b1.read_with(cx_b, |project, cx| {
-        assert!(!project.is_read_only());
+        assert!(!project.is_disconnected());
         assert_eq!(
             project
                 .worktree_for_id(worktree1_id, cx)
@@ -1687,9 +1688,9 @@ async fn test_project_reconnect(
         );
     });
 
-    project_b2.read_with(cx_b, |project, _| assert!(project.is_read_only()));
+    project_b2.read_with(cx_b, |project, _| assert!(project.is_disconnected()));
 
-    project_b3.read_with(cx_b, |project, _| assert!(!project.is_read_only()));
+    project_b3.read_with(cx_b, |project, _| assert!(!project.is_disconnected()));
 
     buffer_a1.read_with(cx_a, |buffer, _| assert_eq!(buffer.text(), "WaZ"));
 
@@ -1746,7 +1747,7 @@ async fn test_project_reconnect(
     executor.run_until_parked();
 
     project_b1.read_with(cx_b, |project, cx| {
-        assert!(!project.is_read_only());
+        assert!(!project.is_disconnected());
         assert_eq!(
             project
                 .worktree_for_id(worktree1_id, cx)
@@ -1780,7 +1781,7 @@ async fn test_project_reconnect(
         );
     });
 
-    project_b3.read_with(cx_b, |project, _| assert!(project.is_read_only()));
+    project_b3.read_with(cx_b, |project, _| assert!(project.is_disconnected()));
 
     buffer_a1.read_with(cx_a, |buffer, _| assert_eq!(buffer.text(), "WXaYZ"));
 
@@ -3535,7 +3536,7 @@ async fn test_leaving_project(
     });
 
     project_b2.read_with(cx_b, |project, _| {
-        assert!(project.is_read_only());
+        assert!(project.is_disconnected());
     });
 
     project_c.read_with(cx_c, |project, _| {
@@ -3550,6 +3551,7 @@ async fn test_leaving_project(
             client_b.user_store().clone(),
             client_b.language_registry().clone(),
             FakeFs::new(cx.background_executor().clone()),
+            ChannelRole::Member,
             cx,
         )
     })
@@ -3568,11 +3570,11 @@ async fn test_leaving_project(
     });
 
     project_b2.read_with(cx_b, |project, _| {
-        assert!(project.is_read_only());
+        assert!(project.is_disconnected());
     });
 
     project_c.read_with(cx_c, |project, _| {
-        assert!(project.is_read_only());
+        assert!(project.is_disconnected());
     });
 }
 

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

@@ -1149,7 +1149,7 @@ impl RandomizedTest for ProjectCollaborationTest {
                             Some((project, cx))
                         });
 
-                        if !guest_project.is_read_only() {
+                        if !guest_project.is_disconnected() {
                             if let Some((host_project, host_cx)) = host_project {
                                 let host_worktree_snapshots =
                                     host_project.read_with(host_cx, |host_project, cx| {
@@ -1236,7 +1236,7 @@ impl RandomizedTest for ProjectCollaborationTest {
             let buffers = client.buffers().clone();
             for (guest_project, guest_buffers) in &buffers {
                 let project_id = if guest_project.read_with(client_cx, |project, _| {
-                    project.is_local() || project.is_read_only()
+                    project.is_local() || project.is_disconnected()
                 }) {
                     continue;
                 } else {

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

@@ -518,7 +518,7 @@ impl<T: RandomizedTest> TestPlan<T> {
                 for project in client.remote_projects().iter() {
                     project.read_with(&client_cx, |project, _| {
                         assert!(
-                            project.is_read_only(),
+                            project.is_disconnected(),
                             "project {:?} should be read only",
                             project.remote_id()
                         )

crates/collab_ui/src/channel_view.rs 🔗

@@ -138,12 +138,6 @@ impl ChannelView {
             editor.set_collaboration_hub(Box::new(ChannelBufferCollaborationHub(
                 channel_buffer.clone(),
             )));
-            editor.set_read_only(
-                !channel_buffer
-                    .read(cx)
-                    .channel(cx)
-                    .is_some_and(|c| c.can_edit_notes()),
-            );
             editor
         });
         let _editor_event_subscription =
@@ -178,8 +172,7 @@ impl ChannelView {
                 cx.notify();
             }),
             ChannelBufferEvent::ChannelChanged => {
-                self.editor.update(cx, |editor, cx| {
-                    editor.set_read_only(!self.channel(cx).is_some_and(|c| c.can_edit_notes()));
+                self.editor.update(cx, |_, cx| {
                     cx.emit(editor::EditorEvent::TitleChanged);
                     cx.notify()
                 });
@@ -254,11 +247,11 @@ impl Item for ChannelView {
     fn tab_content(&self, _: Option<usize>, selected: bool, cx: &WindowContext) -> AnyElement {
         let label = if let Some(channel) = self.channel(cx) {
             match (
-                channel.can_edit_notes(),
+                self.channel_buffer.read(cx).buffer().read(cx).read_only(),
                 self.channel_buffer.read(cx).is_connected(),
             ) {
-                (true, true) => format!("#{}", channel.name),
-                (false, true) => format!("#{} (read-only)", channel.name),
+                (false, true) => format!("#{}", channel.name),
+                (true, true) => format!("#{} (read-only)", channel.name),
                 (_, false) => format!("#{} (disconnected)", channel.name),
             }
         } else {

crates/collab_ui/src/collab_panel.rs 🔗

@@ -151,6 +151,10 @@ enum ListEntry {
         peer_id: Option<PeerId>,
         is_last: bool,
     },
+    GuestCount {
+        count: usize,
+        has_visible_participants: bool,
+    },
     IncomingRequest(Arc<User>),
     OutgoingRequest(Arc<User>),
     ChannelInvite(Arc<Channel>),
@@ -380,10 +384,14 @@ impl CollabPanel {
 
             if !self.collapsed_sections.contains(&Section::ActiveCall) {
                 let room = room.read(cx);
+                let mut guest_count_ix = 0;
+                let mut guest_count = if room.read_only() { 1 } else { 0 };
+                let mut non_guest_count = if room.read_only() { 0 } else { 1 };
 
                 if let Some(channel_id) = room.channel_id() {
                     self.entries.push(ListEntry::ChannelNotes { channel_id });
-                    self.entries.push(ListEntry::ChannelChat { channel_id })
+                    self.entries.push(ListEntry::ChannelChat { channel_id });
+                    guest_count_ix = self.entries.len();
                 }
 
                 // Populate the active user.
@@ -402,7 +410,7 @@ impl CollabPanel {
                         &Default::default(),
                         executor.clone(),
                     ));
-                    if !matches.is_empty() {
+                    if !matches.is_empty() && !room.read_only() {
                         let user_id = user.id;
                         self.entries.push(ListEntry::CallParticipant {
                             user,
@@ -430,13 +438,23 @@ impl CollabPanel {
                 // Populate remote participants.
                 self.match_candidates.clear();
                 self.match_candidates
-                    .extend(room.remote_participants().iter().map(|(_, participant)| {
-                        StringMatchCandidate {
-                            id: participant.user.id as usize,
-                            string: participant.user.github_login.clone(),
-                            char_bag: participant.user.github_login.chars().collect(),
-                        }
-                    }));
+                    .extend(
+                        room.remote_participants()
+                            .iter()
+                            .filter_map(|(_, participant)| {
+                                if participant.role == proto::ChannelRole::Guest {
+                                    guest_count += 1;
+                                    return None;
+                                } else {
+                                    non_guest_count += 1;
+                                }
+                                Some(StringMatchCandidate {
+                                    id: participant.user.id as usize,
+                                    string: participant.user.github_login.clone(),
+                                    char_bag: participant.user.github_login.chars().collect(),
+                                })
+                            }),
+                    );
                 let matches = executor.block(match_strings(
                     &self.match_candidates,
                     &query,
@@ -470,6 +488,15 @@ impl CollabPanel {
                         });
                     }
                 }
+                if guest_count > 0 {
+                    self.entries.insert(
+                        guest_count_ix,
+                        ListEntry::GuestCount {
+                            count: guest_count,
+                            has_visible_participants: non_guest_count > 0,
+                        },
+                    );
+                }
 
                 // Populate pending participants.
                 self.match_candidates.clear();
@@ -959,6 +986,41 @@ impl CollabPanel {
             .tooltip(move |cx| Tooltip::text("Open Chat", cx))
     }
 
+    fn render_guest_count(
+        &self,
+        count: usize,
+        has_visible_participants: bool,
+        is_selected: bool,
+        cx: &mut ViewContext<Self>,
+    ) -> impl IntoElement {
+        let manageable_channel_id = ActiveCall::global(cx).read(cx).room().and_then(|room| {
+            let room = room.read(cx);
+            if room.local_participant_is_admin() {
+                room.channel_id()
+            } else {
+                None
+            }
+        });
+
+        ListItem::new("guest_count")
+            .selected(is_selected)
+            .start_slot(
+                h_stack()
+                    .gap_1()
+                    .child(render_tree_branch(!has_visible_participants, cx))
+                    .child(""),
+            )
+            .child(Label::new(if count == 1 {
+                format!("{} guest", count)
+            } else {
+                format!("{} guests", count)
+            }))
+            .when_some(manageable_channel_id, |el, channel_id| {
+                el.tooltip(move |cx| Tooltip::text("Manage Members", cx))
+                    .on_click(cx.listener(move |this, _, cx| this.manage_members(channel_id, cx)))
+            })
+    }
+
     fn has_subchannels(&self, ix: usize) -> bool {
         self.entries.get(ix).map_or(false, |entry| {
             if let ListEntry::Channel { has_children, .. } = entry {
@@ -1180,6 +1242,18 @@ impl CollabPanel {
                             });
                         }
                     }
+                    ListEntry::GuestCount { .. } => {
+                        let Some(room) = ActiveCall::global(cx).read(cx).room() else {
+                            return;
+                        };
+                        let room = room.read(cx);
+                        let Some(channel_id) = room.channel_id() else {
+                            return;
+                        };
+                        if room.local_participant_is_admin() {
+                            self.manage_members(channel_id, cx)
+                        }
+                    }
                     ListEntry::Channel { channel, .. } => {
                         let is_active = maybe!({
                             let call_channel = ActiveCall::global(cx)
@@ -1735,6 +1809,12 @@ impl CollabPanel {
             ListEntry::ParticipantScreen { peer_id, is_last } => self
                 .render_participant_screen(*peer_id, *is_last, is_selected, cx)
                 .into_any_element(),
+            ListEntry::GuestCount {
+                count,
+                has_visible_participants,
+            } => self
+                .render_guest_count(*count, *has_visible_participants, is_selected, cx)
+                .into_any_element(),
             ListEntry::ChannelNotes { channel_id } => self
                 .render_channel_notes(*channel_id, is_selected, cx)
                 .into_any_element(),
@@ -1766,7 +1846,7 @@ impl CollabPanel {
     ) -> impl IntoElement {
         let settings = ThemeSettings::get_global(cx);
         let text_style = TextStyle {
-            color: if editor.read(cx).read_only() {
+            color: if editor.read(cx).read_only(cx) {
                 cx.theme().colors().text_disabled
             } else {
                 cx.theme().colors().text
@@ -2538,6 +2618,11 @@ impl PartialEq for ListEntry {
                     return true;
                 }
             }
+            ListEntry::GuestCount { .. } => {
+                if let ListEntry::GuestCount { .. } = other {
+                    return true;
+                }
+            }
         }
         false
     }

crates/collab_ui/src/collab_titlebar_item.rs 🔗

@@ -10,6 +10,7 @@ use gpui::{
 };
 use project::{Project, RepositoryEntry};
 use recent_projects::RecentProjects;
+use rpc::proto;
 use std::sync::Arc;
 use theme::{ActiveTheme, PlayerColors};
 use ui::{
@@ -175,8 +176,9 @@ impl Render for CollabTitlebarItem {
                         let is_muted = room.is_muted(cx);
                         let is_deafened = room.is_deafened().unwrap_or(false);
                         let is_screen_sharing = room.is_screen_sharing();
+                        let read_only = room.read_only();
 
-                        this.when(is_local, |this| {
+                        this.when(is_local && !read_only, |this| {
                             this.child(
                                 Button::new(
                                     "toggle_sharing",
@@ -207,21 +209,23 @@ impl Render for CollabTitlebarItem {
                                         .detach_and_log_err(cx);
                                 }),
                         )
-                        .child(
-                            IconButton::new(
-                                "mute-microphone",
-                                if is_muted {
-                                    ui::Icon::MicMute
-                                } else {
-                                    ui::Icon::Mic
-                                },
+                        .when(!read_only, |this| {
+                            this.child(
+                                IconButton::new(
+                                    "mute-microphone",
+                                    if is_muted {
+                                        ui::Icon::MicMute
+                                    } else {
+                                        ui::Icon::Mic
+                                    },
+                                )
+                                .style(ButtonStyle::Subtle)
+                                .icon_size(IconSize::Small)
+                                .selected(is_muted)
+                                .selected_style(ButtonStyle::Tinted(TintColor::Negative))
+                                .on_click(move |_, cx| crate::toggle_mute(&Default::default(), cx)),
                             )
-                            .style(ButtonStyle::Subtle)
-                            .selected_style(ButtonStyle::Tinted(TintColor::Negative))
-                            .icon_size(IconSize::Small)
-                            .selected(is_muted)
-                            .on_click(move |_, cx| crate::toggle_mute(&Default::default(), cx)),
-                        )
+                        })
                         .child(
                             IconButton::new(
                                 "mute-sound",
@@ -236,20 +240,31 @@ impl Render for CollabTitlebarItem {
                             .icon_size(IconSize::Small)
                             .selected(is_deafened)
                             .tooltip(move |cx| {
-                                Tooltip::with_meta("Deafen Audio", None, "Mic will be muted", cx)
+                                if !read_only {
+                                    Tooltip::with_meta(
+                                        "Deafen Audio",
+                                        None,
+                                        "Mic will be muted",
+                                        cx,
+                                    )
+                                } else {
+                                    Tooltip::text("Deafen Audio", cx)
+                                }
                             })
-                            .on_click(move |_, cx| crate::toggle_mute(&Default::default(), cx)),
-                        )
-                        .child(
-                            IconButton::new("screen-share", ui::Icon::Screen)
-                                .style(ButtonStyle::Subtle)
-                                .selected_style(ButtonStyle::Tinted(TintColor::Accent))
-                                .icon_size(IconSize::Small)
-                                .selected(is_screen_sharing)
-                                .on_click(move |_, cx| {
-                                    crate::toggle_screen_sharing(&Default::default(), cx)
-                                }),
+                            .on_click(move |_, cx| crate::toggle_deafen(&Default::default(), cx)),
                         )
+                        .when(!read_only, |this| {
+                            this.child(
+                                IconButton::new("screen-share", ui::Icon::Screen)
+                                    .style(ButtonStyle::Subtle)
+                                    .icon_size(IconSize::Small)
+                                    .selected(is_screen_sharing)
+                                    .selected_style(ButtonStyle::Tinted(TintColor::Accent))
+                                    .on_click(move |_, cx| {
+                                        crate::toggle_screen_sharing(&Default::default(), cx)
+                                    }),
+                            )
+                        })
                     })
                     .map(|el| {
                         let status = self.client.status();
@@ -414,6 +429,10 @@ impl CollabTitlebarItem {
         current_user: &Arc<User>,
         cx: &ViewContext<Self>,
     ) -> Option<FacePile> {
+        if room.role_for_user(user.id) == Some(proto::ChannelRole::Guest) {
+            return None;
+        }
+
         let followers = project_id.map_or(&[] as &[_], |id| room.followers_for(peer_id, id));
 
         let pile = FacePile::default()

crates/diagnostics/src/diagnostics.rs 🔗

@@ -151,7 +151,12 @@ impl ProjectDiagnosticsEditor {
         let focus_in_subscription =
             cx.on_focus_in(&focus_handle, |diagnostics, cx| diagnostics.focus_in(cx));
 
-        let excerpts = cx.new_model(|cx| MultiBuffer::new(project_handle.read(cx).replica_id()));
+        let excerpts = cx.new_model(|cx| {
+            MultiBuffer::new(
+                project_handle.read(cx).replica_id(),
+                project_handle.read(cx).capability(),
+            )
+        });
         let editor = cx.new_view(|cx| {
             let mut editor =
                 Editor::for_multibuffer(excerpts.clone(), Some(project_handle.clone()), cx);

crates/editor/src/editor.rs 🔗

@@ -54,10 +54,10 @@ use itertools::Itertools;
 pub use language::{char_kind, CharKind};
 use language::{
     language_settings::{self, all_language_settings, InlayHintSettings},
-    markdown, point_from_lsp, AutoindentMode, BracketPair, Buffer, CodeAction, CodeLabel,
-    Completion, CursorShape, Diagnostic, Documentation, IndentKind, IndentSize, Language,
-    LanguageRegistry, LanguageServerName, OffsetRangeExt, Point, Selection, SelectionGoal,
-    TransactionId,
+    markdown, point_from_lsp, AutoindentMode, BracketPair, Buffer, Capability, CodeAction,
+    CodeLabel, Completion, CursorShape, Diagnostic, Documentation, IndentKind, IndentSize,
+    Language, LanguageRegistry, LanguageServerName, OffsetRangeExt, Point, Selection,
+    SelectionGoal, TransactionId,
 };
 
 use link_go_to_definition::{GoToDefinitionLink, InlayHighlight, LinkGoToDefinitionState};
@@ -2049,8 +2049,8 @@ impl Editor {
         }
     }
 
-    pub fn read_only(&self) -> bool {
-        self.read_only
+    pub fn read_only(&self, cx: &AppContext) -> bool {
+        self.read_only || self.buffer.read(cx).read_only()
     }
 
     pub fn set_read_only(&mut self, read_only: bool) {
@@ -2199,7 +2199,7 @@ impl Editor {
         S: ToOffset,
         T: Into<Arc<str>>,
     {
-        if self.read_only {
+        if self.read_only(cx) {
             return;
         }
 
@@ -2213,7 +2213,7 @@ impl Editor {
         S: ToOffset,
         T: Into<Arc<str>>,
     {
-        if self.read_only {
+        if self.read_only(cx) {
             return;
         }
 
@@ -2232,7 +2232,7 @@ impl Editor {
         S: ToOffset,
         T: Into<Arc<str>>,
     {
-        if self.read_only {
+        if self.read_only(cx) {
             return;
         }
 
@@ -2596,7 +2596,7 @@ impl Editor {
     pub fn handle_input(&mut self, text: &str, cx: &mut ViewContext<Self>) {
         let text: Arc<str> = text.into();
 
-        if self.read_only {
+        if self.read_only(cx) {
             return;
         }
 
@@ -3049,7 +3049,7 @@ impl Editor {
         autoindent_mode: Option<AutoindentMode>,
         cx: &mut ViewContext<Self>,
     ) {
-        if self.read_only {
+        if self.read_only(cx) {
             return;
         }
 
@@ -3786,7 +3786,8 @@ impl Editor {
 
         let mut ranges_to_highlight = Vec::new();
         let excerpt_buffer = cx.new_model(|cx| {
-            let mut multibuffer = MultiBuffer::new(replica_id).with_title(title);
+            let mut multibuffer =
+                MultiBuffer::new(replica_id, Capability::ReadWrite).with_title(title);
             for (buffer_handle, transaction) in &entries {
                 let buffer = buffer_handle.read(cx);
                 ranges_to_highlight.extend(
@@ -7491,9 +7492,10 @@ impl Editor {
         locations.sort_by_key(|location| location.buffer.read(cx).remote_id());
         let mut locations = locations.into_iter().peekable();
         let mut ranges_to_highlight = Vec::new();
+        let capability = workspace.project().read(cx).capability();
 
         let excerpt_buffer = cx.new_model(|cx| {
-            let mut multibuffer = MultiBuffer::new(replica_id);
+            let mut multibuffer = MultiBuffer::new(replica_id, capability);
             while let Some(location) = locations.next() {
                 let buffer = location.buffer.read(cx);
                 let mut ranges_for_buffer = Vec::new();
@@ -8608,7 +8610,8 @@ impl Editor {
     }
 
     pub fn show_local_cursors(&self, cx: &WindowContext) -> bool {
-        self.blink_manager.read(cx).visible() && self.focus_handle.is_focused(cx)
+        (self.read_only(cx) || self.blink_manager.read(cx).visible())
+            && self.focus_handle.is_focused(cx)
     }
 
     fn on_buffer_changed(&mut self, _: Model<MultiBuffer>, cx: &mut ViewContext<Self>) {

crates/editor/src/editor_tests.rs 🔗

@@ -17,8 +17,9 @@ use gpui::{
 use indoc::indoc;
 use language::{
     language_settings::{AllLanguageSettings, AllLanguageSettingsContent, LanguageSettingsContent},
-    BracketPairConfig, FakeLspAdapter, LanguageConfig, LanguageConfigOverride, LanguageRegistry,
-    Override, Point,
+    BracketPairConfig,
+    Capability::ReadWrite,
+    FakeLspAdapter, LanguageConfig, LanguageConfigOverride, LanguageRegistry, Override, Point,
 };
 use parking_lot::Mutex;
 use project::project_settings::{LspSettings, ProjectSettings};
@@ -2355,7 +2356,7 @@ fn test_indent_outdent_with_excerpts(cx: &mut TestAppContext) {
             .with_language(rust_language, cx)
     });
     let multibuffer = cx.new_model(|cx| {
-        let mut multibuffer = MultiBuffer::new(0);
+        let mut multibuffer = MultiBuffer::new(0, ReadWrite);
         multibuffer.push_excerpts(
             toml_buffer.clone(),
             [ExcerptRange {
@@ -6019,7 +6020,7 @@ fn test_editing_disjoint_excerpts(cx: &mut TestAppContext) {
 
     let buffer = cx.new_model(|cx| Buffer::new(0, cx.entity_id().as_u64(), sample_text(3, 4, 'a')));
     let multibuffer = cx.new_model(|cx| {
-        let mut multibuffer = MultiBuffer::new(0);
+        let mut multibuffer = MultiBuffer::new(0, ReadWrite);
         multibuffer.push_excerpts(
             buffer.clone(),
             [
@@ -6103,7 +6104,7 @@ fn test_editing_overlapping_excerpts(cx: &mut TestAppContext) {
     });
     let buffer = cx.new_model(|cx| Buffer::new(0, cx.entity_id().as_u64(), initial_text));
     let multibuffer = cx.new_model(|cx| {
-        let mut multibuffer = MultiBuffer::new(0);
+        let mut multibuffer = MultiBuffer::new(0, ReadWrite);
         multibuffer.push_excerpts(buffer, excerpt_ranges, cx);
         multibuffer
     });
@@ -6162,7 +6163,7 @@ fn test_refresh_selections(cx: &mut TestAppContext) {
     let buffer = cx.new_model(|cx| Buffer::new(0, cx.entity_id().as_u64(), sample_text(3, 4, 'a')));
     let mut excerpt1_id = None;
     let multibuffer = cx.new_model(|cx| {
-        let mut multibuffer = MultiBuffer::new(0);
+        let mut multibuffer = MultiBuffer::new(0, ReadWrite);
         excerpt1_id = multibuffer
             .push_excerpts(
                 buffer.clone(),
@@ -6247,7 +6248,7 @@ fn test_refresh_selections_while_selecting_with_mouse(cx: &mut TestAppContext) {
     let buffer = cx.new_model(|cx| Buffer::new(0, cx.entity_id().as_u64(), sample_text(3, 4, 'a')));
     let mut excerpt1_id = None;
     let multibuffer = cx.new_model(|cx| {
-        let mut multibuffer = MultiBuffer::new(0);
+        let mut multibuffer = MultiBuffer::new(0, ReadWrite);
         excerpt1_id = multibuffer
             .push_excerpts(
                 buffer.clone(),
@@ -6636,7 +6637,7 @@ async fn test_following_with_multiple_excerpts(cx: &mut gpui::TestAppContext) {
     let cx = &mut VisualTestContext::from_window(*workspace.deref(), cx);
 
     let leader = pane.update(cx, |_, cx| {
-        let multibuffer = cx.new_model(|_| MultiBuffer::new(0));
+        let multibuffer = cx.new_model(|_| MultiBuffer::new(0, ReadWrite));
         cx.new_view(|cx| build_editor(multibuffer.clone(), cx))
     });
 
@@ -7425,7 +7426,7 @@ async fn test_copilot_multibuffer(executor: BackgroundExecutor, cx: &mut gpui::T
     let buffer_1 = cx.new_model(|cx| Buffer::new(0, cx.entity_id().as_u64(), "a = 1\nb = 2\n"));
     let buffer_2 = cx.new_model(|cx| Buffer::new(0, cx.entity_id().as_u64(), "c = 3\nd = 4\n"));
     let multibuffer = cx.new_model(|cx| {
-        let mut multibuffer = MultiBuffer::new(0);
+        let mut multibuffer = MultiBuffer::new(0, ReadWrite);
         multibuffer.push_excerpts(
             buffer_1.clone(),
             [ExcerptRange {
@@ -7552,7 +7553,7 @@ async fn test_copilot_disabled_globs(executor: BackgroundExecutor, cx: &mut gpui
         .unwrap();
 
     let multibuffer = cx.new_model(|cx| {
-        let mut multibuffer = MultiBuffer::new(0);
+        let mut multibuffer = MultiBuffer::new(0, ReadWrite);
         multibuffer.push_excerpts(
             private_buffer.clone(),
             [ExcerptRange {

crates/editor/src/element.rs 🔗

@@ -1910,7 +1910,13 @@ impl EditorElement {
                     layouts.push(layout);
                 }
 
-                selections.push((style.local_player, layouts));
+                let player = if editor.read_only(cx) {
+                    cx.theme().players().read_only()
+                } else {
+                    style.local_player
+                };
+
+                selections.push((player, layouts));
             }
 
             if let Some(collaboration_hub) = &editor.collaboration_hub {

crates/editor/src/git.rs 🔗

@@ -93,6 +93,7 @@ mod tests {
     use crate::editor_tests::init_test;
     use crate::Point;
     use gpui::{Context, TestAppContext};
+    use language::Capability::ReadWrite;
     use multi_buffer::{ExcerptRange, MultiBuffer};
     use project::{FakeFs, Project};
     use unindent::Unindent;
@@ -183,7 +184,7 @@ mod tests {
         cx.background_executor.run_until_parked();
 
         let multibuffer = cx.new_model(|cx| {
-            let mut multibuffer = MultiBuffer::new(0);
+            let mut multibuffer = MultiBuffer::new(0, ReadWrite);
             multibuffer.push_excerpts(
                 buffer_1.clone(),
                 [

crates/editor/src/inlay_hint_cache.rs 🔗

@@ -1206,7 +1206,8 @@ pub mod tests {
     use gpui::{Context, TestAppContext, WindowHandle};
     use itertools::Itertools;
     use language::{
-        language_settings::AllLanguageSettingsContent, FakeLspAdapter, Language, LanguageConfig,
+        language_settings::AllLanguageSettingsContent, Capability, FakeLspAdapter, Language,
+        LanguageConfig,
     };
     use lsp::FakeLanguageServer;
     use parking_lot::Mutex;
@@ -2459,7 +2460,7 @@ pub mod tests {
             .await
             .unwrap();
         let multibuffer = cx.new_model(|cx| {
-            let mut multibuffer = MultiBuffer::new(0);
+            let mut multibuffer = MultiBuffer::new(0, Capability::ReadWrite);
             multibuffer.push_excerpts(
                 buffer_1.clone(),
                 [
@@ -2798,7 +2799,7 @@ pub mod tests {
             })
             .await
             .unwrap();
-        let multibuffer = cx.new_model(|_| MultiBuffer::new(0));
+        let multibuffer = cx.new_model(|_| MultiBuffer::new(0, Capability::ReadWrite));
         let (buffer_1_excerpts, buffer_2_excerpts) = multibuffer.update(cx, |multibuffer, cx| {
             let buffer_1_excerpts = multibuffer.push_excerpts(
                 buffer_1.clone(),

crates/editor/src/items.rs 🔗

@@ -103,7 +103,8 @@ impl FollowableItem for Editor {
                         if state.singleton && buffers.len() == 1 {
                             multibuffer = MultiBuffer::singleton(buffers.pop().unwrap(), cx)
                         } else {
-                            multibuffer = MultiBuffer::new(replica_id);
+                            multibuffer =
+                                MultiBuffer::new(replica_id, project.read(cx).capability());
                             let mut excerpts = state.excerpts.into_iter().peekable();
                             while let Some(excerpt) = excerpts.peek() {
                                 let buffer_id = excerpt.buffer_id;

crates/editor/src/movement.rs 🔗

@@ -461,6 +461,7 @@ mod tests {
         Buffer, DisplayMap, ExcerptRange, InlayId, MultiBuffer,
     };
     use gpui::{font, Context as _};
+    use language::Capability;
     use project::Project;
     use settings::SettingsStore;
     use util::post_inc;
@@ -766,7 +767,7 @@ mod tests {
             let buffer =
                 cx.new_model(|cx| Buffer::new(0, cx.entity_id().as_u64(), "abc\ndefg\nhijkl\nmn"));
             let multibuffer = cx.new_model(|cx| {
-                let mut multibuffer = MultiBuffer::new(0);
+                let mut multibuffer = MultiBuffer::new(0, Capability::ReadWrite);
                 multibuffer.push_excerpts(
                     buffer.clone(),
                     [

crates/gpui/src/color.rs 🔗

@@ -339,6 +339,15 @@ impl Hsla {
         }
     }
 
+    pub fn grayscale(&self) -> Self {
+        Hsla {
+            h: self.h,
+            s: 0.,
+            l: self.l,
+            a: self.a,
+        }
+    }
+
     /// Fade out the color by a given factor. This factor should be between 0.0 and 1.0.
     /// Where 0.0 will leave the color unchanged, and 1.0 will completely fade out the color.
     pub fn fade_out(&mut self, factor: f32) {

crates/gpui/src/element.rs 🔗

@@ -44,8 +44,9 @@ pub trait IntoElement: Sized {
     }
 
     /// Convert into an element, then draw in the current window at the given origin.
-    /// The provided available space is provided to the layout engine to determine the size of the root element.
-    /// Once the element is drawn, its associated element staet is yielded to the given callback.
+    /// The available space argument is provided to the layout engine to determine the size of the
+    // root element.  Once the element is drawn, its associated element state is yielded to the
+    // given callback.
     fn draw_and_update_state<T, R>(
         self,
         origin: Point<Pixels>,

crates/language/src/buffer.rs 🔗

@@ -57,6 +57,12 @@ lazy_static! {
     pub static ref BUFFER_DIFF_TASK: TaskLabel = TaskLabel::new();
 }
 
+#[derive(PartialEq, Clone, Copy, Debug)]
+pub enum Capability {
+    ReadWrite,
+    ReadOnly,
+}
+
 pub struct Buffer {
     text: TextBuffer,
     diff_base: Option<String>,
@@ -90,6 +96,7 @@ pub struct Buffer {
     completion_triggers: Vec<String>,
     completion_triggers_timestamp: clock::Lamport,
     deferred_ops: OperationQueue<Operation>,
+    capability: Capability,
 }
 
 pub struct BufferSnapshot {
@@ -405,19 +412,27 @@ impl Buffer {
             TextBuffer::new(replica_id, id, base_text.into()),
             None,
             None,
+            Capability::ReadWrite,
         )
     }
 
-    pub fn remote(remote_id: u64, replica_id: ReplicaId, base_text: String) -> Self {
+    pub fn remote(
+        remote_id: u64,
+        replica_id: ReplicaId,
+        capability: Capability,
+        base_text: String,
+    ) -> Self {
         Self::build(
             TextBuffer::new(replica_id, remote_id, base_text),
             None,
             None,
+            capability,
         )
     }
 
     pub fn from_proto(
         replica_id: ReplicaId,
+        capability: Capability,
         message: proto::BufferState,
         file: Option<Arc<dyn File>>,
     ) -> Result<Self> {
@@ -426,6 +441,7 @@ impl Buffer {
             buffer,
             message.diff_base.map(|text| text.into_boxed_str().into()),
             file,
+            capability,
         );
         this.text.set_line_ending(proto::deserialize_line_ending(
             rpc::proto::LineEnding::from_i32(message.line_ending)
@@ -504,10 +520,19 @@ impl Buffer {
         self
     }
 
+    pub fn capability(&self) -> Capability {
+        self.capability
+    }
+
+    pub fn read_only(&self) -> bool {
+        self.capability == Capability::ReadOnly
+    }
+
     pub fn build(
         buffer: TextBuffer,
         diff_base: Option<String>,
         file: Option<Arc<dyn File>>,
+        capability: Capability,
     ) -> Self {
         let saved_mtime = if let Some(file) = file.as_ref() {
             file.mtime()
@@ -526,6 +551,7 @@ impl Buffer {
             diff_base,
             git_diff: git::diff::BufferDiff::new(),
             file,
+            capability,
             syntax_map: Mutex::new(SyntaxMap::new()),
             parsing_in_background: false,
             parse_count: 0,

crates/language/src/buffer_tests.rs 🔗

@@ -1926,7 +1926,7 @@ fn test_serialization(cx: &mut gpui::AppContext) {
         .background_executor()
         .block(buffer1.read(cx).serialize_ops(None, cx));
     let buffer2 = cx.new_model(|cx| {
-        let mut buffer = Buffer::from_proto(1, state, None).unwrap();
+        let mut buffer = Buffer::from_proto(1, Capability::ReadWrite, state, None).unwrap();
         buffer
             .apply_ops(
                 ops.into_iter()
@@ -1967,7 +1967,8 @@ fn test_random_collaboration(cx: &mut AppContext, mut rng: StdRng) {
             let ops = cx
                 .background_executor()
                 .block(base_buffer.read(cx).serialize_ops(None, cx));
-            let mut buffer = Buffer::from_proto(i as ReplicaId, state, None).unwrap();
+            let mut buffer =
+                Buffer::from_proto(i as ReplicaId, Capability::ReadWrite, state, None).unwrap();
             buffer
                 .apply_ops(
                     ops.into_iter()
@@ -2083,8 +2084,13 @@ fn test_random_collaboration(cx: &mut AppContext, mut rng: StdRng) {
                     replica_id
                 );
                 new_buffer = Some(cx.new_model(|cx| {
-                    let mut new_buffer =
-                        Buffer::from_proto(new_replica_id, old_buffer_state, None).unwrap();
+                    let mut new_buffer = Buffer::from_proto(
+                        new_replica_id,
+                        Capability::ReadWrite,
+                        old_buffer_state,
+                        None,
+                    )
+                    .unwrap();
                     new_buffer
                         .apply_ops(
                             old_buffer_ops

crates/live_kit_server/src/token.rs 🔗

@@ -62,6 +62,7 @@ impl<'a> VideoGrant<'a> {
         Self {
             room: Some(Cow::Borrowed(room)),
             room_join: Some(true),
+            can_publish: Some(false),
             can_subscribe: Some(true),
             ..Default::default()
         }

crates/multi_buffer/src/multi_buffer.rs 🔗

@@ -11,7 +11,7 @@ pub use language::Completion;
 use language::{
     char_kind,
     language_settings::{language_settings, LanguageSettings},
-    AutoindentMode, Buffer, BufferChunks, BufferSnapshot, CharKind, Chunk, CursorShape,
+    AutoindentMode, Buffer, BufferChunks, BufferSnapshot, Capability, CharKind, Chunk, CursorShape,
     DiagnosticEntry, File, IndentSize, Language, LanguageScope, OffsetRangeExt, OffsetUtf16,
     Outline, OutlineItem, Point, PointUtf16, Selection, TextDimension, ToOffset as _,
     ToOffsetUtf16 as _, ToPoint as _, ToPointUtf16 as _, TransactionId, Unclipped,
@@ -55,6 +55,7 @@ pub struct MultiBuffer {
     replica_id: ReplicaId,
     history: History,
     title: Option<String>,
+    capability: Capability,
 }
 
 #[derive(Clone, Debug, PartialEq, Eq)]
@@ -225,13 +226,14 @@ struct ExcerptBytes<'a> {
 }
 
 impl MultiBuffer {
-    pub fn new(replica_id: ReplicaId) -> Self {
+    pub fn new(replica_id: ReplicaId, capability: Capability) -> Self {
         Self {
             snapshot: Default::default(),
             buffers: Default::default(),
             next_excerpt_id: 1,
             subscriptions: Default::default(),
             singleton: false,
+            capability,
             replica_id,
             history: History {
                 next_transaction_id: Default::default(),
@@ -271,6 +273,7 @@ impl MultiBuffer {
             next_excerpt_id: 1,
             subscriptions: Default::default(),
             singleton: self.singleton,
+            capability: self.capability,
             replica_id: self.replica_id,
             history: self.history.clone(),
             title: self.title.clone(),
@@ -282,8 +285,12 @@ impl MultiBuffer {
         self
     }
 
+    pub fn read_only(&self) -> bool {
+        self.capability == Capability::ReadOnly
+    }
+
     pub fn singleton(buffer: Model<Buffer>, cx: &mut ModelContext<Self>) -> Self {
-        let mut this = Self::new(buffer.read(cx).replica_id());
+        let mut this = Self::new(buffer.read(cx).replica_id(), buffer.read(cx).capability());
         this.singleton = true;
         this.push_excerpts(
             buffer,
@@ -1657,7 +1664,7 @@ impl MultiBuffer {
         excerpts: [(&str, Vec<Range<Point>>); COUNT],
         cx: &mut gpui::AppContext,
     ) -> Model<Self> {
-        let multi = cx.new_model(|_| Self::new(0));
+        let multi = cx.new_model(|_| Self::new(0, Capability::ReadWrite));
         for (text, ranges) in excerpts {
             let buffer = cx.new_model(|cx| Buffer::new(0, cx.entity_id().as_u64(), text));
             let excerpt_ranges = ranges.into_iter().map(|range| ExcerptRange {
@@ -1678,7 +1685,7 @@ impl MultiBuffer {
 
     pub fn build_random(rng: &mut impl rand::Rng, cx: &mut gpui::AppContext) -> Model<Self> {
         cx.new_model(|cx| {
-            let mut multibuffer = MultiBuffer::new(0);
+            let mut multibuffer = MultiBuffer::new(0, Capability::ReadWrite);
             let mutation_count = rng.gen_range(1..=5);
             multibuffer.randomly_edit_excerpts(rng, mutation_count, cx);
             multibuffer
@@ -4176,7 +4183,7 @@ mod tests {
             let ops = cx
                 .background_executor()
                 .block(host_buffer.read(cx).serialize_ops(None, cx));
-            let mut buffer = Buffer::from_proto(1, state, None).unwrap();
+            let mut buffer = Buffer::from_proto(1, Capability::ReadWrite, state, None).unwrap();
             buffer
                 .apply_ops(
                     ops.into_iter()
@@ -4205,7 +4212,7 @@ mod tests {
             cx.new_model(|cx| Buffer::new(0, cx.entity_id().as_u64(), sample_text(6, 6, 'a')));
         let buffer_2 =
             cx.new_model(|cx| Buffer::new(0, cx.entity_id().as_u64(), sample_text(6, 6, 'g')));
-        let multibuffer = cx.new_model(|_| MultiBuffer::new(0));
+        let multibuffer = cx.new_model(|_| MultiBuffer::new(0, Capability::ReadWrite));
 
         let events = Arc::new(RwLock::new(Vec::<Event>::new()));
         multibuffer.update(cx, |_, cx| {
@@ -4442,8 +4449,8 @@ mod tests {
         let buffer_2 =
             cx.new_model(|cx| Buffer::new(0, cx.entity_id().as_u64(), sample_text(10, 3, 'm')));
 
-        let leader_multibuffer = cx.new_model(|_| MultiBuffer::new(0));
-        let follower_multibuffer = cx.new_model(|_| MultiBuffer::new(0));
+        let leader_multibuffer = cx.new_model(|_| MultiBuffer::new(0, Capability::ReadWrite));
+        let follower_multibuffer = cx.new_model(|_| MultiBuffer::new(0, Capability::ReadWrite));
         let follower_edit_event_count = Arc::new(RwLock::new(0));
 
         follower_multibuffer.update(cx, |_, cx| {
@@ -4547,7 +4554,7 @@ mod tests {
     fn test_push_excerpts_with_context_lines(cx: &mut AppContext) {
         let buffer =
             cx.new_model(|cx| Buffer::new(0, cx.entity_id().as_u64(), sample_text(20, 3, 'a')));
-        let multibuffer = cx.new_model(|_| MultiBuffer::new(0));
+        let multibuffer = cx.new_model(|_| MultiBuffer::new(0, Capability::ReadWrite));
         let anchor_ranges = multibuffer.update(cx, |multibuffer, cx| {
             multibuffer.push_excerpts_with_context_lines(
                 buffer.clone(),
@@ -4584,7 +4591,7 @@ mod tests {
     async fn test_stream_excerpts_with_context_lines(cx: &mut TestAppContext) {
         let buffer =
             cx.new_model(|cx| Buffer::new(0, cx.entity_id().as_u64(), sample_text(20, 3, 'a')));
-        let multibuffer = cx.new_model(|_| MultiBuffer::new(0));
+        let multibuffer = cx.new_model(|_| MultiBuffer::new(0, Capability::ReadWrite));
         let anchor_ranges = multibuffer.update(cx, |multibuffer, cx| {
             let snapshot = buffer.read(cx);
             let ranges = vec![
@@ -4619,7 +4626,7 @@ mod tests {
 
     #[gpui::test]
     fn test_empty_multibuffer(cx: &mut AppContext) {
-        let multibuffer = cx.new_model(|_| MultiBuffer::new(0));
+        let multibuffer = cx.new_model(|_| MultiBuffer::new(0, Capability::ReadWrite));
 
         let snapshot = multibuffer.read(cx).snapshot(cx);
         assert_eq!(snapshot.text(), "");
@@ -4652,7 +4659,7 @@ mod tests {
         let buffer_1 = cx.new_model(|cx| Buffer::new(0, cx.entity_id().as_u64(), "abcd"));
         let buffer_2 = cx.new_model(|cx| Buffer::new(0, cx.entity_id().as_u64(), "efghi"));
         let multibuffer = cx.new_model(|cx| {
-            let mut multibuffer = MultiBuffer::new(0);
+            let mut multibuffer = MultiBuffer::new(0, Capability::ReadWrite);
             multibuffer.push_excerpts(
                 buffer_1.clone(),
                 [ExcerptRange {
@@ -4710,7 +4717,7 @@ mod tests {
         let buffer_1 = cx.new_model(|cx| Buffer::new(0, cx.entity_id().as_u64(), "abcd"));
         let buffer_2 =
             cx.new_model(|cx| Buffer::new(0, cx.entity_id().as_u64(), "ABCDEFGHIJKLMNOP"));
-        let multibuffer = cx.new_model(|_| MultiBuffer::new(0));
+        let multibuffer = cx.new_model(|_| MultiBuffer::new(0, Capability::ReadWrite));
 
         // Create an insertion id in buffer 1 that doesn't exist in buffer 2.
         // Add an excerpt from buffer 1 that spans this new insertion.
@@ -4844,7 +4851,7 @@ mod tests {
             .unwrap_or(10);
 
         let mut buffers: Vec<Model<Buffer>> = Vec::new();
-        let multibuffer = cx.new_model(|_| MultiBuffer::new(0));
+        let multibuffer = cx.new_model(|_| MultiBuffer::new(0, Capability::ReadWrite));
         let mut excerpt_ids = Vec::<ExcerptId>::new();
         let mut expected_excerpts = Vec::<(Model<Buffer>, Range<text::Anchor>)>::new();
         let mut anchors = Vec::new();
@@ -5266,7 +5273,7 @@ mod tests {
 
         let buffer_1 = cx.new_model(|cx| Buffer::new(0, cx.entity_id().as_u64(), "1234"));
         let buffer_2 = cx.new_model(|cx| Buffer::new(0, cx.entity_id().as_u64(), "5678"));
-        let multibuffer = cx.new_model(|_| MultiBuffer::new(0));
+        let multibuffer = cx.new_model(|_| MultiBuffer::new(0, Capability::ReadWrite));
         let group_interval = multibuffer.read(cx).history.group_interval;
         multibuffer.update(cx, |multibuffer, cx| {
             multibuffer.push_excerpts(

crates/project/src/project.rs 🔗

@@ -39,11 +39,11 @@ use language::{
         deserialize_anchor, deserialize_fingerprint, deserialize_line_ending, deserialize_version,
         serialize_anchor, serialize_version, split_operations,
     },
-    range_from_lsp, range_to_lsp, Bias, Buffer, BufferSnapshot, CachedLspAdapter, CodeAction,
-    CodeLabel, Completion, Diagnostic, DiagnosticEntry, DiagnosticSet, Diff, Event as BufferEvent,
-    File as _, Language, LanguageRegistry, LanguageServerName, LocalFile, LspAdapterDelegate,
-    OffsetRangeExt, Operation, Patch, PendingLanguageServer, PointUtf16, TextBufferSnapshot,
-    ToOffset, ToPointUtf16, Transaction, Unclipped,
+    range_from_lsp, range_to_lsp, Bias, Buffer, BufferSnapshot, CachedLspAdapter, Capability,
+    CodeAction, CodeLabel, Completion, Diagnostic, DiagnosticEntry, DiagnosticSet, Diff,
+    Event as BufferEvent, File as _, Language, LanguageRegistry, LanguageServerName, LocalFile,
+    LspAdapterDelegate, OffsetRangeExt, Operation, Patch, PendingLanguageServer, PointUtf16,
+    TextBufferSnapshot, ToOffset, ToPointUtf16, Transaction, Unclipped,
 };
 use log::error;
 use lsp::{
@@ -262,6 +262,7 @@ enum ProjectClientState {
     },
     Remote {
         sharing_has_stopped: bool,
+        capability: Capability,
         remote_id: u64,
         replica_id: ReplicaId,
     },
@@ -702,6 +703,7 @@ impl Project {
         user_store: Model<UserStore>,
         languages: Arc<LanguageRegistry>,
         fs: Arc<dyn Fs>,
+        role: proto::ChannelRole,
         mut cx: AsyncAppContext,
     ) -> Result<Model<Self>> {
         client.authenticate_and_connect(true, &cx).await?;
@@ -756,6 +758,7 @@ impl Project {
                 client: client.clone(),
                 client_state: Some(ProjectClientState::Remote {
                     sharing_has_stopped: false,
+                    capability: Capability::ReadWrite,
                     remote_id,
                     replica_id,
                 }),
@@ -796,6 +799,7 @@ impl Project {
                 prettiers_per_worktree: HashMap::default(),
                 prettier_instances: HashMap::default(),
             };
+            this.set_role(role);
             for worktree in worktrees {
                 let _ = this.add_worktree(&worktree, cx);
             }
@@ -1618,6 +1622,17 @@ impl Project {
         cx.notify();
     }
 
+    pub fn set_role(&mut self, role: proto::ChannelRole) {
+        if let Some(ProjectClientState::Remote { capability, .. }) = &mut self.client_state {
+            *capability = if role == proto::ChannelRole::Member || role == proto::ChannelRole::Admin
+            {
+                Capability::ReadWrite
+            } else {
+                Capability::ReadOnly
+            };
+        }
+    }
+
     fn disconnected_from_host_internal(&mut self, cx: &mut AppContext) {
         if let Some(ProjectClientState::Remote {
             sharing_has_stopped,
@@ -1659,7 +1674,7 @@ impl Project {
         cx.emit(Event::Closed);
     }
 
-    pub fn is_read_only(&self) -> bool {
+    pub fn is_disconnected(&self) -> bool {
         match &self.client_state {
             Some(ProjectClientState::Remote {
                 sharing_has_stopped,
@@ -1669,6 +1684,17 @@ impl Project {
         }
     }
 
+    pub fn capability(&self) -> Capability {
+        match &self.client_state {
+            Some(ProjectClientState::Remote { capability, .. }) => *capability,
+            Some(ProjectClientState::Local { .. }) | None => Capability::ReadWrite,
+        }
+    }
+
+    pub fn is_read_only(&self) -> bool {
+        self.is_disconnected() || self.capability() == Capability::ReadOnly
+    }
+
     pub fn is_local(&self) -> bool {
         match &self.client_state {
             Some(ProjectClientState::Remote { .. }) => false,
@@ -6013,7 +6039,7 @@ impl Project {
             this.upgrade().context("project dropped")?;
             let response = rpc.request(message).await?;
             let this = this.upgrade().context("project dropped")?;
-            if this.update(&mut cx, |this, _| this.is_read_only())? {
+            if this.update(&mut cx, |this, _| this.is_disconnected())? {
                 Err(anyhow!("disconnected before completing request"))
             } else {
                 request
@@ -7192,7 +7218,8 @@ impl Project {
 
                     let buffer_id = state.id;
                     let buffer = cx.new_model(|_| {
-                        Buffer::from_proto(this.replica_id(), state, buffer_file).unwrap()
+                        Buffer::from_proto(this.replica_id(), this.capability(), state, buffer_file)
+                            .unwrap()
                     });
                     this.incomplete_remote_buffers
                         .insert(buffer_id, Some(buffer));
@@ -7940,7 +7967,7 @@ impl Project {
 
                 if let Some(buffer) = buffer {
                     break buffer;
-                } else if this.update(&mut cx, |this, _| this.is_read_only())? {
+                } else if this.update(&mut cx, |this, _| this.is_disconnected())? {
                     return Err(anyhow!("disconnected before buffer {} could be opened", id));
                 }
 

crates/project/src/worktree.rs 🔗

@@ -32,7 +32,8 @@ use language::{
         deserialize_fingerprint, deserialize_version, serialize_fingerprint, serialize_line_ending,
         serialize_version,
     },
-    Buffer, DiagnosticEntry, File as _, LineEnding, PointUtf16, Rope, RopeFingerprint, Unclipped,
+    Buffer, Capability, DiagnosticEntry, File as _, LineEnding, PointUtf16, Rope, RopeFingerprint,
+    Unclipped,
 };
 use lsp::LanguageServerId;
 use parking_lot::Mutex;
@@ -682,7 +683,14 @@ impl LocalWorktree {
                 .background_executor()
                 .spawn(async move { text::Buffer::new(0, id, contents) })
                 .await;
-            cx.new_model(|_| Buffer::build(text_buffer, diff_base, Some(Arc::new(file))))
+            cx.new_model(|_| {
+                Buffer::build(
+                    text_buffer,
+                    diff_base,
+                    Some(Arc::new(file)),
+                    Capability::ReadWrite,
+                )
+            })
         })
     }
 

crates/project_panel/src/project_panel.rs 🔗

@@ -388,8 +388,18 @@ impl ProjectPanel {
             let is_dir = entry.is_dir();
             let worktree_id = worktree.id();
             let is_local = project.is_local();
+            let is_read_only = project.is_read_only();
 
             let context_menu = ContextMenu::build(cx, |mut menu, cx| {
+                if is_read_only {
+                    menu = menu.action("Copy Relative Path", Box::new(CopyRelativePath));
+                    if is_dir {
+                        menu = menu.action("Search Inside", Box::new(NewSearchInDirectory))
+                    }
+
+                    return menu;
+                }
+
                 if is_local {
                     menu = menu.action(
                         "Add Folder to Project",
@@ -1473,6 +1483,7 @@ impl ProjectPanel {
 impl Render for ProjectPanel {
     fn render(&mut self, cx: &mut gpui::ViewContext<Self>) -> impl IntoElement {
         let has_worktree = self.visible_entries.len() != 0;
+        let project = self.project.read(cx);
 
         if has_worktree {
             div()
@@ -1485,21 +1496,25 @@ impl Render for ProjectPanel {
                 .on_action(cx.listener(Self::expand_selected_entry))
                 .on_action(cx.listener(Self::collapse_selected_entry))
                 .on_action(cx.listener(Self::collapse_all_entries))
-                .on_action(cx.listener(Self::new_file))
-                .on_action(cx.listener(Self::new_directory))
-                .on_action(cx.listener(Self::rename))
-                .on_action(cx.listener(Self::delete))
-                .on_action(cx.listener(Self::confirm))
                 .on_action(cx.listener(Self::open_file))
+                .on_action(cx.listener(Self::confirm))
                 .on_action(cx.listener(Self::cancel))
-                .on_action(cx.listener(Self::cut))
-                .on_action(cx.listener(Self::copy))
                 .on_action(cx.listener(Self::copy_path))
                 .on_action(cx.listener(Self::copy_relative_path))
-                .on_action(cx.listener(Self::paste))
-                .on_action(cx.listener(Self::reveal_in_finder))
-                .on_action(cx.listener(Self::open_in_terminal))
                 .on_action(cx.listener(Self::new_search_in_directory))
+                .when(!project.is_read_only(), |el| {
+                    el.on_action(cx.listener(Self::new_file))
+                        .on_action(cx.listener(Self::new_directory))
+                        .on_action(cx.listener(Self::rename))
+                        .on_action(cx.listener(Self::delete))
+                        .on_action(cx.listener(Self::cut))
+                        .on_action(cx.listener(Self::copy))
+                        .on_action(cx.listener(Self::paste))
+                })
+                .when(project.is_local(), |el| {
+                    el.on_action(cx.listener(Self::reveal_in_finder))
+                        .on_action(cx.listener(Self::open_in_terminal))
+                })
                 .track_focus(&self.focus_handle)
                 .child(
                     uniform_list(

crates/rpc/proto/zed.proto 🔗

@@ -269,6 +269,7 @@ message Participant {
     repeated ParticipantProject projects = 3;
     ParticipantLocation location = 4;
     uint32 participant_index = 5;
+    ChannelRole role = 6;
 }
 
 message PendingParticipant {

crates/search/src/buffer_search.rs 🔗

@@ -70,7 +70,7 @@ impl BufferSearchBar {
     fn render_text_input(&self, editor: &View<Editor>, cx: &ViewContext<Self>) -> impl IntoElement {
         let settings = ThemeSettings::get_global(cx);
         let text_style = TextStyle {
-            color: if editor.read(cx).read_only() {
+            color: if editor.read(cx).read_only(cx) {
                 cx.theme().colors().text_disabled
             } else {
                 cx.theme().colors().text

crates/search/src/project_search.rs 🔗

@@ -132,9 +132,11 @@ pub struct ProjectSearchBar {
 impl ProjectSearch {
     fn new(project: Model<Project>, cx: &mut ModelContext<Self>) -> Self {
         let replica_id = project.read(cx).replica_id();
+        let capability = project.read(cx).capability();
+
         Self {
             project,
-            excerpts: cx.new_model(|_| MultiBuffer::new(replica_id)),
+            excerpts: cx.new_model(|_| MultiBuffer::new(replica_id, capability)),
             pending_search: Default::default(),
             match_ranges: Default::default(),
             active_query: None,
@@ -1556,7 +1558,7 @@ impl ProjectSearchBar {
     fn render_text_input(&self, editor: &View<Editor>, cx: &ViewContext<Self>) -> impl IntoElement {
         let settings = ThemeSettings::get_global(cx);
         let text_style = TextStyle {
-            color: if editor.read(cx).read_only() {
+            color: if editor.read(cx).read_only(cx) {
                 cx.theme().colors().text_disabled
             } else {
                 cx.theme().colors().text

crates/theme/src/styles/players.rs 🔗

@@ -131,6 +131,15 @@ impl PlayerColors {
         *self.0.last().unwrap()
     }
 
+    pub fn read_only(&self) -> PlayerColor {
+        let local = self.local();
+        PlayerColor {
+            cursor: local.cursor.grayscale(),
+            background: local.background.grayscale(),
+            selection: local.selection.grayscale(),
+        }
+    }
+
     pub fn color_for_participant(&self, participant_index: u32) -> PlayerColor {
         let len = self.0.len() - 1;
         self.0[(participant_index as usize % len) + 1]

crates/workspace/src/workspace.rs 🔗

@@ -1187,7 +1187,7 @@ impl Workspace {
         mut save_intent: SaveIntent,
         cx: &mut ViewContext<Self>,
     ) -> Task<Result<bool>> {
-        if self.project.read(cx).is_read_only() {
+        if self.project.read(cx).is_disconnected() {
             return Task::ready(Ok(true));
         }
         let dirty_items = self
@@ -2510,7 +2510,7 @@ impl Workspace {
     }
 
     fn update_window_edited(&mut self, cx: &mut ViewContext<Self>) {
-        let is_edited = !self.project.read(cx).is_read_only()
+        let is_edited = !self.project.read(cx).is_disconnected()
             && self
                 .items(cx)
                 .any(|item| item.has_conflict(cx) || item.is_dirty(cx));
@@ -3635,7 +3635,7 @@ impl Render for Workspace {
                     })),
             )
             .child(self.status_bar.clone())
-            .children(if self.project.read(cx).is_read_only() {
+            .children(if self.project.read(cx).is_disconnected() {
                 Some(DisconnectedOverlay)
             } else {
                 None