workspace: Invert dependency on call crate by extracting into a trait (#49968)

Jakub Konka and Piotr Osiewicz created

Release Notes:

- N/A

Co-authored-by: Piotr Osiewicz <piotr@zed.dev>

Change summary

Cargo.lock                                           |   1 
crates/call/Cargo.toml                               |   5 
crates/call/src/call_impl/mod.rs                     | 260 +++++++++++
crates/call/src/call_impl/participant.rs             |  27 -
crates/call/src/call_impl/room.rs                    |   3 
crates/collab/tests/integration/following_tests.rs   |   7 
crates/collab/tests/integration/integration_tests.rs |   4 
crates/git_ui/src/git_panel.rs                       |  13 
crates/title_bar/src/collab.rs                       |   4 
crates/workspace/Cargo.toml                          |   1 
crates/workspace/src/pane_group.rs                   |  15 
crates/workspace/src/shared_screen.rs                |  37 -
crates/workspace/src/workspace.rs                    | 311 ++++++++-----
13 files changed, 481 insertions(+), 207 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -2484,6 +2484,7 @@ dependencies = [
  "settings",
  "telemetry",
  "util",
+ "workspace",
 ]
 
 [[package]]

crates/call/Cargo.toml 🔗

@@ -31,7 +31,9 @@ fs.workspace = true
 futures.workspace = true
 feature_flags.workspace = true
 gpui = { workspace = true, features = ["screen-capture"] }
+gpui_tokio.workspace = true
 language.workspace = true
+livekit_client.workspace = true
 log.workspace = true
 postage.workspace = true
 project.workspace = true
@@ -39,8 +41,7 @@ serde.workspace = true
 settings.workspace = true
 telemetry.workspace = true
 util.workspace = true
-gpui_tokio.workspace = true
-livekit_client.workspace = true
+workspace.workspace = true
 
 [dev-dependencies]
 client = { workspace = true, features = ["test-support"] }

crates/call/src/call_impl/mod.rs 🔗

@@ -7,25 +7,265 @@ use client::{ChannelId, Client, TypedEnvelope, User, UserStore, ZED_ALWAYS_ACTIV
 use collections::HashSet;
 use futures::{Future, FutureExt, channel::oneshot, future::Shared};
 use gpui::{
-    App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, Global, Subscription, Task,
-    WeakEntity,
+    AnyView, App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, Subscription, Task,
+    WeakEntity, Window,
 };
 use postage::watch;
 use project::Project;
 use room::Event;
+use settings::Settings;
 use std::sync::Arc;
+use workspace::{
+    ActiveCallEvent, AnyActiveCall, GlobalAnyActiveCall, Pane, RemoteCollaborator, SharedScreen,
+    Workspace,
+};
 
 pub use livekit_client::{RemoteVideoTrack, RemoteVideoTrackView, RemoteVideoTrackViewEvent};
-pub use participant::ParticipantLocation;
 pub use room::Room;
 
-struct GlobalActiveCall(Entity<ActiveCall>);
-
-impl Global for GlobalActiveCall {}
+use crate::call_settings::CallSettings;
 
 pub fn init(client: Arc<Client>, user_store: Entity<UserStore>, cx: &mut App) {
     let active_call = cx.new(|cx| ActiveCall::new(client, user_store, cx));
-    cx.set_global(GlobalActiveCall(active_call));
+    cx.set_global(GlobalAnyActiveCall(Arc::new(ActiveCallEntity(active_call))))
+}
+
+#[derive(Clone)]
+struct ActiveCallEntity(Entity<ActiveCall>);
+
+impl AnyActiveCall for ActiveCallEntity {
+    fn entity(&self) -> gpui::AnyEntity {
+        self.0.clone().into_any()
+    }
+
+    fn is_in_room(&self, cx: &App) -> bool {
+        self.0.read(cx).room().is_some()
+    }
+
+    fn room_id(&self, cx: &App) -> Option<u64> {
+        Some(self.0.read(cx).room()?.read(cx).id())
+    }
+
+    fn channel_id(&self, cx: &App) -> Option<ChannelId> {
+        self.0.read(cx).room()?.read(cx).channel_id()
+    }
+
+    fn hang_up(&self, cx: &mut App) -> Task<Result<()>> {
+        self.0.update(cx, |this, cx| this.hang_up(cx))
+    }
+
+    fn unshare_project(&self, project: Entity<Project>, cx: &mut App) -> Result<()> {
+        self.0
+            .update(cx, |this, cx| this.unshare_project(project, cx))
+    }
+
+    fn remote_participant_for_peer_id(
+        &self,
+        peer_id: proto::PeerId,
+        cx: &App,
+    ) -> Option<workspace::RemoteCollaborator> {
+        let room = self.0.read(cx).room()?.read(cx);
+        let participant = room.remote_participant_for_peer_id(peer_id)?;
+        Some(RemoteCollaborator {
+            user: participant.user.clone(),
+            peer_id: participant.peer_id,
+            location: participant.location,
+            participant_index: participant.participant_index,
+        })
+    }
+
+    fn is_sharing_project(&self, cx: &App) -> bool {
+        self.0
+            .read(cx)
+            .room()
+            .map_or(false, |room| room.read(cx).is_sharing_project())
+    }
+
+    fn has_remote_participants(&self, cx: &App) -> bool {
+        self.0.read(cx).room().map_or(false, |room| {
+            !room.read(cx).remote_participants().is_empty()
+        })
+    }
+
+    fn local_participant_is_guest(&self, cx: &App) -> bool {
+        self.0
+            .read(cx)
+            .room()
+            .map_or(false, |room| room.read(cx).local_participant_is_guest())
+    }
+
+    fn client(&self, cx: &App) -> Arc<Client> {
+        self.0.read(cx).client()
+    }
+
+    fn share_on_join(&self, cx: &App) -> bool {
+        CallSettings::get_global(cx).share_on_join
+    }
+
+    fn join_channel(&self, channel_id: ChannelId, cx: &mut App) -> Task<Result<bool>> {
+        let task = self
+            .0
+            .update(cx, |this, cx| this.join_channel(channel_id, cx));
+        cx.spawn(async move |_cx| {
+            let result = task.await?;
+            Ok(result.is_some())
+        })
+    }
+
+    fn room_update_completed(&self, cx: &mut App) -> Task<()> {
+        let Some(room) = self.0.read(cx).room().cloned() else {
+            return Task::ready(());
+        };
+        let future = room.update(cx, |room, _cx| room.room_update_completed());
+        cx.spawn(async move |_cx| {
+            future.await;
+        })
+    }
+
+    fn most_active_project(&self, cx: &App) -> Option<(u64, u64)> {
+        let room = self.0.read(cx).room()?;
+        room.read(cx).most_active_project(cx)
+    }
+
+    fn share_project(&self, project: Entity<Project>, cx: &mut App) -> Task<Result<u64>> {
+        self.0
+            .update(cx, |this, cx| this.share_project(project, cx))
+    }
+
+    fn join_project(
+        &self,
+        project_id: u64,
+        language_registry: Arc<language::LanguageRegistry>,
+        fs: Arc<dyn fs::Fs>,
+        cx: &mut App,
+    ) -> Task<Result<Entity<Project>>> {
+        let Some(room) = self.0.read(cx).room().cloned() else {
+            return Task::ready(Err(anyhow::anyhow!("not in a call")));
+        };
+        room.update(cx, |room, cx| {
+            room.join_project(project_id, language_registry, fs, cx)
+        })
+    }
+
+    fn peer_id_for_user_in_room(&self, user_id: u64, cx: &App) -> Option<proto::PeerId> {
+        let room = self.0.read(cx).room()?.read(cx);
+        room.remote_participants()
+            .values()
+            .find(|p| p.user.id == user_id)
+            .map(|p| p.peer_id)
+    }
+
+    fn subscribe(
+        &self,
+        window: &mut Window,
+        cx: &mut Context<Workspace>,
+        handler: Box<
+            dyn Fn(&mut Workspace, &ActiveCallEvent, &mut Window, &mut Context<Workspace>),
+        >,
+    ) -> Subscription {
+        cx.subscribe_in(
+            &self.0,
+            window,
+            move |workspace, _, event: &room::Event, window, cx| {
+                let mapped = match event {
+                    room::Event::ParticipantLocationChanged { participant_id } => {
+                        Some(ActiveCallEvent::ParticipantLocationChanged {
+                            participant_id: *participant_id,
+                        })
+                    }
+                    room::Event::RemoteVideoTracksChanged { participant_id } => {
+                        Some(ActiveCallEvent::RemoteVideoTracksChanged {
+                            participant_id: *participant_id,
+                        })
+                    }
+                    _ => None,
+                };
+                if let Some(event) = mapped {
+                    handler(workspace, &event, window, cx);
+                }
+            },
+        )
+    }
+
+    fn create_shared_screen(
+        &self,
+        peer_id: client::proto::PeerId,
+        pane: &Entity<Pane>,
+        window: &mut Window,
+        cx: &mut App,
+    ) -> Option<Entity<workspace::SharedScreen>> {
+        let room = self.0.read(cx).room()?.clone();
+        let participant = room.read(cx).remote_participant_for_peer_id(peer_id)?;
+        let track = participant.video_tracks.values().next()?.clone();
+        let user = participant.user.clone();
+
+        for item in pane.read(cx).items_of_type::<SharedScreen>() {
+            if item.read(cx).peer_id == peer_id {
+                return Some(item);
+            }
+        }
+
+        Some(cx.new(|cx: &mut Context<SharedScreen>| {
+            let my_sid = track.sid();
+            cx.subscribe(
+                &room,
+                move |_: &mut SharedScreen,
+                      _: Entity<Room>,
+                      ev: &room::Event,
+                      cx: &mut Context<SharedScreen>| {
+                    if let room::Event::RemoteVideoTrackUnsubscribed { sid } = ev
+                        && *sid == my_sid
+                    {
+                        cx.emit(workspace::shared_screen::Event::Close);
+                    }
+                },
+            )
+            .detach();
+
+            cx.observe_release(
+                &room,
+                |_: &mut SharedScreen, _: &mut Room, cx: &mut Context<SharedScreen>| {
+                    cx.emit(workspace::shared_screen::Event::Close);
+                },
+            )
+            .detach();
+
+            let view = cx.new(|cx| RemoteVideoTrackView::new(track.clone(), window, cx));
+            cx.subscribe(
+                &view,
+                |_: &mut SharedScreen,
+                 _: Entity<RemoteVideoTrackView>,
+                 ev: &RemoteVideoTrackViewEvent,
+                 cx: &mut Context<SharedScreen>| match ev {
+                    RemoteVideoTrackViewEvent::Close => {
+                        cx.emit(workspace::shared_screen::Event::Close);
+                    }
+                },
+            )
+            .detach();
+
+            pub(super) fn clone_remote_video_track_view(
+                view: &AnyView,
+                window: &mut Window,
+                cx: &mut App,
+            ) -> AnyView {
+                let view = view
+                    .clone()
+                    .downcast::<RemoteVideoTrackView>()
+                    .expect("SharedScreen view must be a RemoteVideoTrackView");
+                let cloned = view.update(cx, |view, cx| view.clone(window, cx));
+                AnyView::from(cloned)
+            }
+
+            SharedScreen::new(
+                peer_id,
+                user,
+                AnyView::from(view),
+                clone_remote_video_track_view,
+                cx,
+            )
+        }))
+    }
 }
 
 pub struct OneAtATime {
@@ -152,12 +392,12 @@ impl ActiveCall {
     }
 
     pub fn global(cx: &App) -> Entity<Self> {
-        cx.global::<GlobalActiveCall>().0.clone()
+        Self::try_global(cx).unwrap()
     }
 
     pub fn try_global(cx: &App) -> Option<Entity<Self>> {
-        cx.try_global::<GlobalActiveCall>()
-            .map(|call| call.0.clone())
+        let any = cx.try_global::<GlobalAnyActiveCall>()?;
+        any.0.entity().downcast::<Self>().ok()
     }
 
     pub fn invite(

crates/call/src/call_impl/participant.rs 🔗

@@ -1,4 +1,3 @@
-use anyhow::{Context as _, Result};
 use client::{ParticipantIndex, User, proto};
 use collections::HashMap;
 use gpui::WeakEntity;
@@ -9,30 +8,6 @@ use std::sync::Arc;
 pub use livekit_client::TrackSid;
 pub use livekit_client::{RemoteAudioTrack, RemoteVideoTrack};
 
-#[derive(Copy, Clone, Debug, Eq, PartialEq)]
-pub enum ParticipantLocation {
-    SharedProject { project_id: u64 },
-    UnsharedProject,
-    External,
-}
-
-impl ParticipantLocation {
-    pub fn from_proto(location: Option<proto::ParticipantLocation>) -> Result<Self> {
-        match location
-            .and_then(|l| l.variant)
-            .context("participant location was not provided")?
-        {
-            proto::participant_location::Variant::SharedProject(project) => {
-                Ok(Self::SharedProject {
-                    project_id: project.id,
-                })
-            }
-            proto::participant_location::Variant::UnsharedProject(_) => Ok(Self::UnsharedProject),
-            proto::participant_location::Variant::External(_) => Ok(Self::External),
-        }
-    }
-}
-
 #[derive(Clone, Default)]
 pub struct LocalParticipant {
     pub projects: Vec<proto::ParticipantProject>,
@@ -54,7 +29,7 @@ pub struct RemoteParticipant {
     pub peer_id: proto::PeerId,
     pub role: proto::ChannelRole,
     pub projects: Vec<proto::ParticipantProject>,
-    pub location: ParticipantLocation,
+    pub location: workspace::ParticipantLocation,
     pub participant_index: ParticipantIndex,
     pub muted: bool,
     pub speaking: bool,

crates/call/src/call_impl/room.rs 🔗

@@ -1,6 +1,6 @@
 use crate::{
     call_settings::CallSettings,
-    participant::{LocalParticipant, ParticipantLocation, RemoteParticipant},
+    participant::{LocalParticipant, RemoteParticipant},
 };
 use anyhow::{Context as _, Result, anyhow};
 use audio::{Audio, Sound};
@@ -25,6 +25,7 @@ use project::Project;
 use settings::Settings as _;
 use std::{future::Future, mem, rc::Rc, sync::Arc, time::Duration, time::Instant};
 use util::{ResultExt, TryFutureExt, paths::PathStyle, post_inc};
+use workspace::ParticipantLocation;
 
 pub const RECONNECT_TIMEOUT: Duration = Duration::from_secs(30);
 

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

@@ -1,6 +1,6 @@
 #![allow(clippy::reversed_empty_ranges)]
 use crate::TestServer;
-use call::{ActiveCall, ParticipantLocation};
+use call::ActiveCall;
 use client::ChannelId;
 use collab_ui::{
     channel_view::ChannelView,
@@ -17,7 +17,10 @@ use serde_json::json;
 use settings::SettingsStore;
 use text::{Point, ToPoint};
 use util::{path, rel_path::rel_path, test::sample_text};
-use workspace::{CollaboratorId, MultiWorkspace, SplitDirection, Workspace, item::ItemHandle as _};
+use workspace::{
+    CollaboratorId, MultiWorkspace, ParticipantLocation, SplitDirection, Workspace,
+    item::ItemHandle as _,
+};
 
 use super::TestClient;
 

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

@@ -6,7 +6,7 @@ use anyhow::{Result, anyhow};
 use assistant_slash_command::SlashCommandWorkingSet;
 use assistant_text_thread::TextThreadStore;
 use buffer_diff::{DiffHunkSecondaryStatus, DiffHunkStatus, assert_hunks};
-use call::{ActiveCall, ParticipantLocation, Room, room};
+use call::{ActiveCall, Room, room};
 use client::{RECEIVE_TIMEOUT, User};
 use collab::rpc::{CLEANUP_TIMEOUT, RECONNECT_TIMEOUT};
 use collections::{BTreeMap, HashMap, HashSet};
@@ -51,7 +51,7 @@ use std::{
 };
 use unindent::Unindent as _;
 use util::{path, rel_path::rel_path, uri};
-use workspace::Pane;
+use workspace::{Pane, ParticipantLocation};
 
 #[ctor::ctor]
 fn init_logger() {

crates/git_ui/src/git_panel.rs 🔗

@@ -3257,10 +3257,8 @@ impl GitPanel {
         let mut new_co_authors = Vec::new();
         let project = self.project.read(cx);
 
-        let Some(room) = self
-            .workspace
-            .upgrade()
-            .and_then(|workspace| workspace.read(cx).active_call()?.read(cx).room().cloned())
+        let Some(room) =
+            call::ActiveCall::try_global(cx).and_then(|call| call.read(cx).room().cloned())
         else {
             return Vec::default();
         };
@@ -5520,10 +5518,9 @@ impl Render for GitPanel {
     fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
         let project = self.project.read(cx);
         let has_entries = !self.entries.is_empty();
-        let room = self
-            .workspace
-            .upgrade()
-            .and_then(|workspace| workspace.read(cx).active_call()?.read(cx).room().cloned());
+        let room = self.workspace.upgrade().and_then(|_workspace| {
+            call::ActiveCall::try_global(cx).and_then(|call| call.read(cx).room().cloned())
+        });
 
         let has_write_access = self.has_write_access(cx);
 

crates/title_bar/src/collab.rs 🔗

@@ -1,7 +1,7 @@
 use std::rc::Rc;
 use std::sync::Arc;
 
-use call::{ActiveCall, ParticipantLocation, Room};
+use call::{ActiveCall, Room};
 use channel::ChannelStore;
 use client::{User, proto::PeerId};
 use gpui::{
@@ -18,7 +18,7 @@ use ui::{
     Facepile, PopoverMenu, SplitButton, SplitButtonStyle, TintColor, Tooltip, prelude::*,
 };
 use util::rel_path::RelPath;
-use workspace::notifications::DetachAndPromptErr;
+use workspace::{ParticipantLocation, notifications::DetachAndPromptErr};
 
 use crate::TitleBar;
 

crates/workspace/Cargo.toml 🔗

@@ -30,7 +30,6 @@ test-support = [
 any_vec.workspace = true
 anyhow.workspace = true
 async-recursion.workspace = true
-call.workspace = true
 client.workspace = true
 chrono.workspace = true
 clock.workspace = true

crates/workspace/src/pane_group.rs 🔗

@@ -1,10 +1,10 @@
 use crate::{
-    AppState, CollaboratorId, FollowerState, Pane, Workspace, WorkspaceSettings,
+    AnyActiveCall, AppState, CollaboratorId, FollowerState, Pane, ParticipantLocation, Workspace,
+    WorkspaceSettings,
     pane_group::element::pane_axis,
     workspace_settings::{PaneSplitDirectionHorizontal, PaneSplitDirectionVertical},
 };
 use anyhow::Result;
-use call::{ActiveCall, ParticipantLocation};
 use collections::HashMap;
 use gpui::{
     Along, AnyView, AnyWeakView, Axis, Bounds, Entity, Hsla, IntoElement, MouseButton, Pixels,
@@ -296,7 +296,7 @@ impl Member {
 pub struct PaneRenderContext<'a> {
     pub project: &'a Entity<Project>,
     pub follower_states: &'a HashMap<CollaboratorId, FollowerState>,
-    pub active_call: Option<&'a Entity<ActiveCall>>,
+    pub active_call: Option<&'a dyn AnyActiveCall>,
     pub active_pane: &'a Entity<Pane>,
     pub app_state: &'a Arc<AppState>,
     pub workspace: &'a WeakEntity<Workspace>,
@@ -358,10 +358,11 @@ impl PaneLeaderDecorator for PaneRenderContext<'_> {
         let status_box;
         match leader_id {
             CollaboratorId::PeerId(peer_id) => {
-                let Some(leader) = self.active_call.as_ref().and_then(|call| {
-                    let room = call.read(cx).room()?.read(cx);
-                    room.remote_participant_for_peer_id(peer_id)
-                }) else {
+                let Some(leader) = self
+                    .active_call
+                    .as_ref()
+                    .and_then(|call| call.remote_participant_for_peer_id(peer_id, cx))
+                else {
                     return LeaderDecoration::default();
                 };
 

crates/workspace/src/shared_screen.rs 🔗

@@ -2,10 +2,9 @@ use crate::{
     ItemNavHistory, WorkspaceId,
     item::{Item, ItemEvent},
 };
-use call::{RemoteVideoTrack, RemoteVideoTrackView, Room};
 use client::{User, proto::PeerId};
 use gpui::{
-    AppContext as _, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement,
+    AnyView, AppContext as _, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement,
     ParentElement, Render, SharedString, Styled, Task, div,
 };
 use std::sync::Arc;
@@ -19,45 +18,26 @@ pub struct SharedScreen {
     pub peer_id: PeerId,
     user: Arc<User>,
     nav_history: Option<ItemNavHistory>,
-    view: Entity<RemoteVideoTrackView>,
+    view: AnyView,
+    clone_view: fn(&AnyView, &mut Window, &mut App) -> AnyView,
     focus: FocusHandle,
 }
 
 impl SharedScreen {
     pub fn new(
-        track: RemoteVideoTrack,
         peer_id: PeerId,
         user: Arc<User>,
-        room: Entity<Room>,
-        window: &mut Window,
+        view: AnyView,
+        clone_view: fn(&AnyView, &mut Window, &mut App) -> AnyView,
         cx: &mut Context<Self>,
     ) -> Self {
-        let my_sid = track.sid();
-        cx.subscribe(&room, move |_, _, ev, cx| {
-            if let call::room::Event::RemoteVideoTrackUnsubscribed { sid } = ev
-                && sid == &my_sid
-            {
-                cx.emit(Event::Close)
-            }
-        })
-        .detach();
-
-        cx.observe_release(&room, |_, _, cx| {
-            cx.emit(Event::Close);
-        })
-        .detach();
-
-        let view = cx.new(|cx| RemoteVideoTrackView::new(track.clone(), window, cx));
-        cx.subscribe(&view, |_, _, ev, cx| match ev {
-            call::RemoteVideoTrackViewEvent::Close => cx.emit(Event::Close),
-        })
-        .detach();
         Self {
             view,
             peer_id,
             user,
             nav_history: Default::default(),
             focus: cx.focus_handle(),
+            clone_view,
         }
     }
 }
@@ -124,12 +104,15 @@ impl Item for SharedScreen {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> Task<Option<Entity<Self>>> {
+        let clone_view = self.clone_view;
+        let cloned_view = clone_view(&self.view, window, cx);
         Task::ready(Some(cx.new(|cx| Self {
-            view: self.view.update(cx, |view, cx| view.clone(window, cx)),
+            view: cloned_view,
             peer_id: self.peer_id,
             user: self.user.clone(),
             nav_history: Default::default(),
             focus: cx.focus_handle(),
+            clone_view,
         })))
     }
 

crates/workspace/src/workspace.rs 🔗

@@ -12,6 +12,7 @@ mod persistence;
 pub mod searchable;
 mod security_modal;
 pub mod shared_screen;
+pub use shared_screen::SharedScreen;
 mod status_bar;
 pub mod tasks;
 mod theme_preview;
@@ -31,13 +32,13 @@ pub use path_list::PathList;
 pub use toast_layer::{ToastAction, ToastLayer, ToastView};
 
 use anyhow::{Context as _, Result, anyhow};
-use call::{ActiveCall, call_settings::CallSettings};
 use client::{
-    ChannelId, Client, ErrorExt, Status, TypedEnvelope, UserStore,
+    ChannelId, Client, ErrorExt, ParticipantIndex, Status, TypedEnvelope, User, UserStore,
     proto::{self, ErrorCode, PanelId, PeerId},
 };
 use collections::{HashMap, HashSet, hash_map};
 use dock::{Dock, DockPosition, PanelButtons, PanelHandle, RESIZE_HANDLE_SIZE};
+use fs::Fs;
 use futures::{
     Future, FutureExt, StreamExt,
     channel::{
@@ -97,7 +98,7 @@ use session::AppSession;
 use settings::{
     CenteredPaddingSettings, Settings, SettingsLocation, SettingsStore, update_settings_file,
 };
-use shared_screen::SharedScreen;
+
 use sqlez::{
     bindable::{Bind, Column, StaticColumnCount},
     statement::Statement,
@@ -1258,7 +1259,7 @@ pub struct Workspace {
     window_edited: bool,
     last_window_title: Option<String>,
     dirty_items: HashMap<EntityId, Subscription>,
-    active_call: Option<(Entity<ActiveCall>, Vec<Subscription>)>,
+    active_call: Option<(GlobalAnyActiveCall, Vec<Subscription>)>,
     leader_updates_tx: mpsc::UnboundedSender<(PeerId, proto::UpdateFollowers)>,
     database_id: Option<WorkspaceId>,
     app_state: Arc<AppState>,
@@ -1572,8 +1573,12 @@ impl Workspace {
         let session_id = app_state.session.read(cx).id().to_owned();
 
         let mut active_call = None;
-        if let Some(call) = ActiveCall::try_global(cx) {
-            let subscriptions = vec![cx.subscribe_in(&call, window, Self::on_active_call_event)];
+        if let Some(call) = GlobalAnyActiveCall::try_global(cx).cloned() {
+            let subscriptions =
+                vec![
+                    call.0
+                        .subscribe(window, cx, Box::new(Self::on_active_call_event)),
+                ];
             active_call = Some((call, subscriptions));
         }
 
@@ -2692,7 +2697,7 @@ impl Workspace {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> Task<Result<bool>> {
-        let active_call = self.active_call().cloned();
+        let active_call = self.active_global_call();
 
         cx.spawn_in(window, async move |this, cx| {
             this.update(cx, |this, _| {
@@ -2734,7 +2739,9 @@ impl Workspace {
 
             if let Some(active_call) = active_call
                 && workspace_count == 1
-                && active_call.read_with(cx, |call, _| call.room().is_some())
+                && cx
+                    .update(|_window, cx| active_call.0.is_in_room(cx))
+                    .unwrap_or(false)
             {
                 if close_intent == CloseIntent::CloseWindow {
                     let answer = cx.update(|window, cx| {
@@ -2750,14 +2757,13 @@ impl Workspace {
                     if answer.await.log_err() == Some(1) {
                         return anyhow::Ok(false);
                     } else {
-                        active_call
-                            .update(cx, |call, cx| call.hang_up(cx))
-                            .await
-                            .log_err();
+                        if let Ok(task) = cx.update(|_window, cx| active_call.0.hang_up(cx)) {
+                            task.await.log_err();
+                        }
                     }
                 }
                 if close_intent == CloseIntent::ReplaceWindow {
-                    _ = active_call.update(cx, |this, cx| {
+                    _ = cx.update(|_window, cx| {
                         let multi_workspace = cx
                             .windows()
                             .iter()
@@ -2771,10 +2777,10 @@ impl Workspace {
                             .project
                             .clone();
                         if project.read(cx).is_shared() {
-                            this.unshare_project(project, cx)?;
+                            active_call.0.unshare_project(project, cx)?;
                         }
                         Ok::<_, anyhow::Error>(())
-                    })?;
+                    });
                 }
             }
 
@@ -4944,7 +4950,7 @@ impl Workspace {
 
         match leader_id {
             CollaboratorId::PeerId(leader_peer_id) => {
-                let room_id = self.active_call()?.read(cx).room()?.read(cx).id();
+                let room_id = self.active_call()?.room_id(cx)?;
                 let project_id = self.project.read(cx).remote_id();
                 let request = self.app_state.client.request(proto::Follow {
                     room_id,
@@ -5038,20 +5044,21 @@ impl Workspace {
         let leader_id = leader_id.into();
 
         if let CollaboratorId::PeerId(peer_id) = leader_id {
-            let Some(room) = ActiveCall::global(cx).read(cx).room() else {
+            let Some(active_call) = GlobalAnyActiveCall::try_global(cx) else {
                 return;
             };
-            let room = room.read(cx);
-            let Some(remote_participant) = room.remote_participant_for_peer_id(peer_id) else {
+            let Some(remote_participant) =
+                active_call.0.remote_participant_for_peer_id(peer_id, cx)
+            else {
                 return;
             };
 
             let project = self.project.read(cx);
 
             let other_project_id = match remote_participant.location {
-                call::ParticipantLocation::External => None,
-                call::ParticipantLocation::UnsharedProject => None,
-                call::ParticipantLocation::SharedProject { project_id } => {
+                ParticipantLocation::External => None,
+                ParticipantLocation::UnsharedProject => None,
+                ParticipantLocation::SharedProject { project_id } => {
                     if Some(project_id) == project.remote_id() {
                         None
                     } else {
@@ -5097,7 +5104,7 @@ impl Workspace {
 
         if let CollaboratorId::PeerId(leader_peer_id) = leader_id {
             let project_id = self.project.read(cx).remote_id();
-            let room_id = self.active_call()?.read(cx).room()?.read(cx).id();
+            let room_id = self.active_call()?.room_id(cx)?;
             self.app_state
                 .client
                 .send(proto::Unfollow {
@@ -5740,20 +5747,19 @@ impl Workspace {
         cx: &mut Context<Self>,
     ) -> Option<(Option<PanelId>, Box<dyn ItemHandle>)> {
         let call = self.active_call()?;
-        let room = call.read(cx).room()?.read(cx);
-        let participant = room.remote_participant_for_peer_id(peer_id)?;
+        let participant = call.remote_participant_for_peer_id(peer_id, cx)?;
         let leader_in_this_app;
         let leader_in_this_project;
         match participant.location {
-            call::ParticipantLocation::SharedProject { project_id } => {
+            ParticipantLocation::SharedProject { project_id } => {
                 leader_in_this_app = true;
                 leader_in_this_project = Some(project_id) == self.project.read(cx).remote_id();
             }
-            call::ParticipantLocation::UnsharedProject => {
+            ParticipantLocation::UnsharedProject => {
                 leader_in_this_app = true;
                 leader_in_this_project = false;
             }
-            call::ParticipantLocation::External => {
+            ParticipantLocation::External => {
                 leader_in_this_app = false;
                 leader_in_this_project = false;
             }
@@ -5781,19 +5787,8 @@ impl Workspace {
         window: &mut Window,
         cx: &mut App,
     ) -> Option<Entity<SharedScreen>> {
-        let call = self.active_call()?;
-        let room = call.read(cx).room()?.clone();
-        let participant = room.read(cx).remote_participant_for_peer_id(peer_id)?;
-        let track = participant.video_tracks.values().next()?.clone();
-        let user = participant.user.clone();
-
-        for item in pane.read(cx).items_of_type::<SharedScreen>() {
-            if item.read(cx).peer_id == peer_id {
-                return Some(item);
-            }
-        }
-
-        Some(cx.new(|cx| SharedScreen::new(track, peer_id, user.clone(), room.clone(), window, cx)))
+        self.active_call()?
+            .create_shared_screen(peer_id, pane, window, cx)
     }
 
     pub fn on_window_activation_changed(&mut self, window: &mut Window, cx: &mut Context<Self>) {
@@ -5824,23 +5819,25 @@ impl Workspace {
         }
     }
 
-    pub fn active_call(&self) -> Option<&Entity<ActiveCall>> {
-        self.active_call.as_ref().map(|(call, _)| call)
+    pub fn active_call(&self) -> Option<&dyn AnyActiveCall> {
+        self.active_call.as_ref().map(|(call, _)| &*call.0)
+    }
+
+    pub fn active_global_call(&self) -> Option<GlobalAnyActiveCall> {
+        self.active_call.as_ref().map(|(call, _)| call.clone())
     }
 
     fn on_active_call_event(
         &mut self,
-        _: &Entity<ActiveCall>,
-        event: &call::room::Event,
+        event: &ActiveCallEvent,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
         match event {
-            call::room::Event::ParticipantLocationChanged { participant_id }
-            | call::room::Event::RemoteVideoTracksChanged { participant_id } => {
+            ActiveCallEvent::ParticipantLocationChanged { participant_id }
+            | ActiveCallEvent::RemoteVideoTracksChanged { participant_id } => {
                 self.leader_updated(participant_id, window, cx);
             }
-            _ => {}
         }
     }
 
@@ -7027,6 +7024,98 @@ impl Workspace {
     }
 }
 
+pub trait AnyActiveCall {
+    fn entity(&self) -> AnyEntity;
+    fn is_in_room(&self, _: &App) -> bool;
+    fn room_id(&self, _: &App) -> Option<u64>;
+    fn channel_id(&self, _: &App) -> Option<ChannelId>;
+    fn hang_up(&self, _: &mut App) -> Task<Result<()>>;
+    fn unshare_project(&self, _: Entity<Project>, _: &mut App) -> Result<()>;
+    fn remote_participant_for_peer_id(&self, _: PeerId, _: &App) -> Option<RemoteCollaborator>;
+    fn is_sharing_project(&self, _: &App) -> bool;
+    fn has_remote_participants(&self, _: &App) -> bool;
+    fn local_participant_is_guest(&self, _: &App) -> bool;
+    fn client(&self, _: &App) -> Arc<Client>;
+    fn share_on_join(&self, _: &App) -> bool;
+    fn join_channel(&self, _: ChannelId, _: &mut App) -> Task<Result<bool>>;
+    fn room_update_completed(&self, _: &mut App) -> Task<()>;
+    fn most_active_project(&self, _: &App) -> Option<(u64, u64)>;
+    fn share_project(&self, _: Entity<Project>, _: &mut App) -> Task<Result<u64>>;
+    fn join_project(
+        &self,
+        _: u64,
+        _: Arc<LanguageRegistry>,
+        _: Arc<dyn Fs>,
+        _: &mut App,
+    ) -> Task<Result<Entity<Project>>>;
+    fn peer_id_for_user_in_room(&self, _: u64, _: &App) -> Option<PeerId>;
+    fn subscribe(
+        &self,
+        _: &mut Window,
+        _: &mut Context<Workspace>,
+        _: Box<dyn Fn(&mut Workspace, &ActiveCallEvent, &mut Window, &mut Context<Workspace>)>,
+    ) -> Subscription;
+    fn create_shared_screen(
+        &self,
+        _: PeerId,
+        _: &Entity<Pane>,
+        _: &mut Window,
+        _: &mut App,
+    ) -> Option<Entity<SharedScreen>>;
+}
+
+#[derive(Clone)]
+pub struct GlobalAnyActiveCall(pub Arc<dyn AnyActiveCall>);
+impl Global for GlobalAnyActiveCall {}
+
+impl GlobalAnyActiveCall {
+    pub(crate) fn try_global(cx: &App) -> Option<&Self> {
+        cx.try_global()
+    }
+
+    pub(crate) fn global(cx: &App) -> &Self {
+        cx.global()
+    }
+}
+/// Workspace-local view of a remote participant's location.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum ParticipantLocation {
+    SharedProject { project_id: u64 },
+    UnsharedProject,
+    External,
+}
+
+impl ParticipantLocation {
+    pub fn from_proto(location: Option<proto::ParticipantLocation>) -> Result<Self> {
+        match location
+            .and_then(|l| l.variant)
+            .context("participant location was not provided")?
+        {
+            proto::participant_location::Variant::SharedProject(project) => {
+                Ok(Self::SharedProject {
+                    project_id: project.id,
+                })
+            }
+            proto::participant_location::Variant::UnsharedProject(_) => Ok(Self::UnsharedProject),
+            proto::participant_location::Variant::External(_) => Ok(Self::External),
+        }
+    }
+}
+/// Workspace-local view of a remote collaborator's state.
+/// This is the subset of `call::RemoteParticipant` that workspace needs.
+#[derive(Clone)]
+pub struct RemoteCollaborator {
+    pub user: Arc<User>,
+    pub peer_id: PeerId,
+    pub location: ParticipantLocation,
+    pub participant_index: ParticipantIndex,
+}
+
+pub enum ActiveCallEvent {
+    ParticipantLocationChanged { participant_id: PeerId },
+    RemoteVideoTracksChanged { participant_id: PeerId },
+}
+
 fn leader_border_for_pane(
     follower_states: &HashMap<CollaboratorId, FollowerState>,
     pane: &Entity<Pane>,
@@ -7043,8 +7132,9 @@ fn leader_border_for_pane(
 
     let mut leader_color = match leader_id {
         CollaboratorId::PeerId(leader_peer_id) => {
-            let room = ActiveCall::try_global(cx)?.read(cx).room()?.read(cx);
-            let leader = room.remote_participant_for_peer_id(leader_peer_id)?;
+            let leader = GlobalAnyActiveCall::try_global(cx)?
+                .0
+                .remote_participant_for_peer_id(leader_peer_id, cx)?;
 
             cx.theme()
                 .players()
@@ -7786,8 +7876,8 @@ impl WorkspaceStore {
         update: proto::update_followers::Variant,
         cx: &App,
     ) -> Option<()> {
-        let active_call = ActiveCall::try_global(cx)?;
-        let room_id = active_call.read(cx).room()?.read(cx).id();
+        let active_call = GlobalAnyActiveCall::try_global(cx)?;
+        let room_id = active_call.0.room_id(cx)?;
         self.client
             .send(proto::UpdateFollowers {
                 room_id,
@@ -8100,33 +8190,28 @@ async fn join_channel_internal(
     app_state: &Arc<AppState>,
     requesting_window: Option<WindowHandle<MultiWorkspace>>,
     requesting_workspace: Option<WeakEntity<Workspace>>,
-    active_call: &Entity<ActiveCall>,
+    active_call: &dyn AnyActiveCall,
     cx: &mut AsyncApp,
 ) -> Result<bool> {
-    let (should_prompt, open_room) = active_call.update(cx, |active_call, cx| {
-        let Some(room) = active_call.room().map(|room| room.read(cx)) else {
-            return (false, None);
-        };
+    let (should_prompt, already_in_channel) = cx.update(|cx| {
+        if !active_call.is_in_room(cx) {
+            return (false, false);
+        }
 
-        let already_in_channel = room.channel_id() == Some(channel_id);
-        let should_prompt = room.is_sharing_project()
-            && !room.remote_participants().is_empty()
+        let already_in_channel = active_call.channel_id(cx) == Some(channel_id);
+        let should_prompt = active_call.is_sharing_project(cx)
+            && active_call.has_remote_participants(cx)
             && !already_in_channel;
-        let open_room = if already_in_channel {
-            active_call.room().cloned()
-        } else {
-            None
-        };
-        (should_prompt, open_room)
+        (should_prompt, already_in_channel)
     });
 
-    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_in_room_project(project, host, app_state.clone(), cx));
+    if already_in_channel {
+        let task = cx.update(|cx| {
+            if let Some((project, host)) = active_call.most_active_project(cx) {
+                Some(join_in_room_project(project, host, app_state.clone(), cx))
+            } else {
+                None
             }
-
-            None
         });
         if let Some(task) = task {
             task.await?;
@@ -8152,11 +8237,11 @@ async fn join_channel_internal(
                 return Ok(false);
             }
         } else {
-            return Ok(false); // unreachable!() hopefully
+            return Ok(false);
         }
     }
 
-    let client = cx.update(|cx| active_call.read(cx).client());
+    let client = cx.update(|cx| active_call.client(cx));
 
     let mut client_status = client.status();
 
@@ -8184,33 +8269,30 @@ async fn join_channel_internal(
         }
     }
 
-    let room = active_call
-        .update(cx, |active_call, cx| {
-            active_call.join_channel(channel_id, cx)
-        })
+    let joined = cx
+        .update(|cx| active_call.join_channel(channel_id, cx))
         .await?;
 
-    let Some(room) = room else {
+    if !joined {
         return anyhow::Ok(true);
-    };
+    }
 
-    room.update(cx, |room, _| room.room_update_completed())
-        .await;
+    cx.update(|cx| active_call.room_update_completed(cx)).await;
 
-    let task = room.update(cx, |room, cx| {
-        if let Some((project, host)) = room.most_active_project(cx) {
+    let task = cx.update(|cx| {
+        if let Some((project, host)) = active_call.most_active_project(cx) {
             return Some(join_in_room_project(project, host, app_state.clone(), cx));
         }
 
         // If you are the first to join a channel, see if you should share your project.
-        if room.remote_participants().is_empty()
-            && !room.local_participant_is_guest()
+        if !active_call.has_remote_participants(cx)
+            && !active_call.local_participant_is_guest(cx)
             && let Some(workspace) = requesting_workspace.as_ref().and_then(|w| w.upgrade())
         {
             let project = workspace.update(cx, |workspace, cx| {
                 let project = workspace.project.read(cx);
 
-                if !CallSettings::get_global(cx).share_on_join {
+                if !active_call.share_on_join(cx) {
                     return None;
                 }
 
@@ -8227,9 +8309,9 @@ async fn join_channel_internal(
                 }
             });
             if let Some(project) = project {
-                return Some(cx.spawn(async move |room, cx| {
-                    room.update(cx, |room, cx| room.share_project(project, cx))?
-                        .await?;
+                let share_task = active_call.share_project(project, cx);
+                return Some(cx.spawn(async move |_cx| -> Result<()> {
+                    share_task.await?;
                     Ok(())
                 }));
             }
@@ -8251,14 +8333,14 @@ pub fn join_channel(
     requesting_workspace: Option<WeakEntity<Workspace>>,
     cx: &mut App,
 ) -> Task<Result<()>> {
-    let active_call = ActiveCall::global(cx);
+    let active_call = GlobalAnyActiveCall::global(cx).clone();
     cx.spawn(async move |cx| {
         let result = join_channel_internal(
             channel_id,
             &app_state,
             requesting_window,
             requesting_workspace,
-            &active_call,
+            &*active_call.0,
             cx,
         )
         .await;
@@ -9102,13 +9184,10 @@ pub fn join_in_room_project(
                 .ok();
             existing_window
         } else {
-            let active_call = cx.update(|cx| ActiveCall::global(cx));
-            let room = active_call
-                .read_with(cx, |call, _| call.room().cloned())
-                .context("not in a call")?;
-            let project = room
-                .update(cx, |room, cx| {
-                    room.join_project(
+            let active_call = cx.update(|cx| GlobalAnyActiveCall::global(cx).clone());
+            let project = cx
+                .update(|cx| {
+                    active_call.0.join_project(
                         project_id,
                         app_state.languages.clone(),
                         app_state.fs.clone(),
@@ -9137,27 +9216,21 @@ pub fn join_in_room_project(
             // We set the active workspace above, so this is the correct workspace.
             let workspace = multi_workspace.workspace().clone();
             workspace.update(cx, |workspace, cx| {
-                if let Some(room) = ActiveCall::global(cx).read(cx).room().cloned() {
-                    let follow_peer_id = room
-                        .read(cx)
-                        .remote_participants()
-                        .iter()
-                        .find(|(_, participant)| participant.user.id == follow_user_id)
-                        .map(|(_, p)| p.peer_id)
-                        .or_else(|| {
-                            // If we couldn't follow the given user, follow the host instead.
-                            let collaborator = workspace
-                                .project()
-                                .read(cx)
-                                .collaborators()
-                                .values()
-                                .find(|collaborator| collaborator.is_host)?;
-                            Some(collaborator.peer_id)
-                        });
+                let follow_peer_id = GlobalAnyActiveCall::try_global(cx)
+                    .and_then(|call| call.0.peer_id_for_user_in_room(follow_user_id, cx))
+                    .or_else(|| {
+                        // If we couldn't follow the given user, follow the host instead.
+                        let collaborator = workspace
+                            .project()
+                            .read(cx)
+                            .collaborators()
+                            .values()
+                            .find(|collaborator| collaborator.is_host)?;
+                        Some(collaborator.peer_id)
+                    });
 
-                    if let Some(follow_peer_id) = follow_peer_id {
-                        workspace.follow(follow_peer_id, window, cx);
-                    }
+                if let Some(follow_peer_id) = follow_peer_id {
+                    workspace.follow(follow_peer_id, window, cx);
                 }
             });
         })?;