show host in titlebar (#3072)

Conrad Irwin created

Release Notes:

- show host in the titlebar of shared projects
- clicking on faces in the titlebar will now always follow the person
(it used to toggle)
- clicking on someone in the channel panel will follow that person
- highlight the currently open project in the channel panel

- fixes a bug where sometimes following between workspaces would not
work

Change summary

Procfile                                            |    2 
crates/call/src/room.rs                             |    7 
crates/client/src/user.rs                           |    4 
crates/collab/src/tests.rs                          |    1 
crates/collab/src/tests/channel_buffer_tests.rs     |    4 
crates/collab/src/tests/following_tests.rs          | 1306 +++++++++++++++
crates/collab/src/tests/integration_tests.rs        | 1139 ------------
crates/collab_ui/src/collab_panel.rs                |  265 +-
crates/collab_ui/src/collab_titlebar_item.rs        |  146 +
crates/collab_ui/src/collab_ui.rs                   |    2 
crates/collab_ui/src/project_shared_notification.rs |   15 
crates/gpui/src/platform/test.rs                    |   35 
crates/project/src/project.rs                       |    4 
crates/theme/src/theme.rs                           |    1 
crates/workspace/src/pane_group.rs                  |    2 
crates/workspace/src/workspace.rs                   |   92 
styles/src/style_tree/titlebar.ts                   |    8 
17 files changed, 1,745 insertions(+), 1,288 deletions(-)

Detailed changes

Procfile 🔗

@@ -1,4 +1,4 @@
 web: cd ../zed.dev && PORT=3000 npm run dev
-collab: cd crates/collab && cargo run serve
+collab: cd crates/collab && RUST_LOG=${RUST_LOG:-collab=info} cargo run serve
 livekit: livekit-server --dev
 postgrest: postgrest crates/collab/admin_api.conf

crates/call/src/room.rs 🔗

@@ -44,6 +44,12 @@ pub enum Event {
     RemoteProjectUnshared {
         project_id: u64,
     },
+    RemoteProjectJoined {
+        project_id: u64,
+    },
+    RemoteProjectInvitationDiscarded {
+        project_id: u64,
+    },
     Left,
 }
 
@@ -1015,6 +1021,7 @@ impl Room {
     ) -> Task<Result<ModelHandle<Project>>> {
         let client = self.client.clone();
         let user_store = self.user_store.clone();
+        cx.emit(Event::RemoteProjectJoined { project_id: id });
         cx.spawn(|this, mut cx| async move {
             let project =
                 Project::remote(id, client, user_store, language_registry, fs, cx.clone()).await?;

crates/client/src/user.rs 🔗

@@ -595,6 +595,10 @@ impl UserStore {
         self.load_users(proto::FuzzySearchUsers { query }, cx)
     }
 
+    pub fn get_cached_user(&self, user_id: u64) -> Option<Arc<User>> {
+        self.users.get(&user_id).cloned()
+    }
+
     pub fn get_user(
         &mut self,
         user_id: u64,

crates/collab/src/tests.rs 🔗

@@ -4,6 +4,7 @@ use gpui::{ModelHandle, TestAppContext};
 mod channel_buffer_tests;
 mod channel_message_tests;
 mod channel_tests;
+mod following_tests;
 mod integration_tests;
 mod random_channel_buffer_tests;
 mod random_project_collaboration_tests;

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

@@ -702,9 +702,7 @@ async fn test_following_to_channel_notes_without_a_shared_project(
     // Client B follows client A.
     workspace_b
         .update(cx_b, |workspace, cx| {
-            workspace
-                .toggle_follow(client_a.peer_id().unwrap(), cx)
-                .unwrap()
+            workspace.follow(client_a.peer_id().unwrap(), cx).unwrap()
         })
         .await
         .unwrap();

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

@@ -0,0 +1,1306 @@
+use crate::{rpc::RECONNECT_TIMEOUT, tests::TestServer};
+use call::ActiveCall;
+use collab_ui::project_shared_notification::ProjectSharedNotification;
+use editor::{Editor, ExcerptRange, MultiBuffer};
+use gpui::{executor::Deterministic, geometry::vector::vec2f, TestAppContext, ViewHandle};
+use live_kit_client::MacOSDisplay;
+use serde_json::json;
+use std::{borrow::Cow, sync::Arc};
+use workspace::{
+    dock::{test::TestPanel, DockPosition},
+    item::{test::TestItem, ItemHandle as _},
+    shared_screen::SharedScreen,
+    SplitDirection, Workspace,
+};
+
+#[gpui::test(iterations = 10)]
+async fn test_basic_following(
+    deterministic: Arc<Deterministic>,
+    cx_a: &mut TestAppContext,
+    cx_b: &mut TestAppContext,
+    cx_c: &mut TestAppContext,
+    cx_d: &mut TestAppContext,
+) {
+    deterministic.forbid_parking();
+
+    let mut server = TestServer::start(&deterministic).await;
+    let client_a = server.create_client(cx_a, "user_a").await;
+    let client_b = server.create_client(cx_b, "user_b").await;
+    let client_c = server.create_client(cx_c, "user_c").await;
+    let client_d = server.create_client(cx_d, "user_d").await;
+    server
+        .create_room(&mut [
+            (&client_a, cx_a),
+            (&client_b, cx_b),
+            (&client_c, cx_c),
+            (&client_d, cx_d),
+        ])
+        .await;
+    let active_call_a = cx_a.read(ActiveCall::global);
+    let active_call_b = cx_b.read(ActiveCall::global);
+
+    cx_a.update(editor::init);
+    cx_b.update(editor::init);
+
+    client_a
+        .fs()
+        .insert_tree(
+            "/a",
+            json!({
+                "1.txt": "one\none\none",
+                "2.txt": "two\ntwo\ntwo",
+                "3.txt": "three\nthree\nthree",
+            }),
+        )
+        .await;
+    let (project_a, worktree_id) = client_a.build_local_project("/a", cx_a).await;
+    active_call_a
+        .update(cx_a, |call, cx| call.set_location(Some(&project_a), cx))
+        .await
+        .unwrap();
+
+    let project_id = active_call_a
+        .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
+        .await
+        .unwrap();
+    let project_b = client_b.build_remote_project(project_id, cx_b).await;
+    active_call_b
+        .update(cx_b, |call, cx| call.set_location(Some(&project_b), cx))
+        .await
+        .unwrap();
+
+    let window_a = client_a.build_workspace(&project_a, cx_a);
+    let workspace_a = window_a.root(cx_a);
+    let window_b = client_b.build_workspace(&project_b, cx_b);
+    let workspace_b = window_b.root(cx_b);
+
+    // Client A opens some editors.
+    let pane_a = workspace_a.read_with(cx_a, |workspace, _| workspace.active_pane().clone());
+    let editor_a1 = workspace_a
+        .update(cx_a, |workspace, cx| {
+            workspace.open_path((worktree_id, "1.txt"), None, true, cx)
+        })
+        .await
+        .unwrap()
+        .downcast::<Editor>()
+        .unwrap();
+    let editor_a2 = workspace_a
+        .update(cx_a, |workspace, cx| {
+            workspace.open_path((worktree_id, "2.txt"), None, true, cx)
+        })
+        .await
+        .unwrap()
+        .downcast::<Editor>()
+        .unwrap();
+
+    // Client B opens an editor.
+    let editor_b1 = workspace_b
+        .update(cx_b, |workspace, cx| {
+            workspace.open_path((worktree_id, "1.txt"), None, true, cx)
+        })
+        .await
+        .unwrap()
+        .downcast::<Editor>()
+        .unwrap();
+
+    let peer_id_a = client_a.peer_id().unwrap();
+    let peer_id_b = client_b.peer_id().unwrap();
+    let peer_id_c = client_c.peer_id().unwrap();
+    let peer_id_d = client_d.peer_id().unwrap();
+
+    // Client A updates their selections in those editors
+    editor_a1.update(cx_a, |editor, cx| {
+        editor.handle_input("a", cx);
+        editor.handle_input("b", cx);
+        editor.handle_input("c", cx);
+        editor.select_left(&Default::default(), cx);
+        assert_eq!(editor.selections.ranges(cx), vec![3..2]);
+    });
+    editor_a2.update(cx_a, |editor, cx| {
+        editor.handle_input("d", cx);
+        editor.handle_input("e", cx);
+        editor.select_left(&Default::default(), cx);
+        assert_eq!(editor.selections.ranges(cx), vec![2..1]);
+    });
+
+    // When client B starts following client A, all visible view states are replicated to client B.
+    workspace_b
+        .update(cx_b, |workspace, cx| {
+            workspace.follow(peer_id_a, cx).unwrap()
+        })
+        .await
+        .unwrap();
+
+    cx_c.foreground().run_until_parked();
+    let editor_b2 = workspace_b.read_with(cx_b, |workspace, cx| {
+        workspace
+            .active_item(cx)
+            .unwrap()
+            .downcast::<Editor>()
+            .unwrap()
+    });
+    assert_eq!(
+        cx_b.read(|cx| editor_b2.project_path(cx)),
+        Some((worktree_id, "2.txt").into())
+    );
+    assert_eq!(
+        editor_b2.read_with(cx_b, |editor, cx| editor.selections.ranges(cx)),
+        vec![2..1]
+    );
+    assert_eq!(
+        editor_b1.read_with(cx_b, |editor, cx| editor.selections.ranges(cx)),
+        vec![3..2]
+    );
+
+    cx_c.foreground().run_until_parked();
+    let active_call_c = cx_c.read(ActiveCall::global);
+    let project_c = client_c.build_remote_project(project_id, cx_c).await;
+    let window_c = client_c.build_workspace(&project_c, cx_c);
+    let workspace_c = window_c.root(cx_c);
+    active_call_c
+        .update(cx_c, |call, cx| call.set_location(Some(&project_c), cx))
+        .await
+        .unwrap();
+    drop(project_c);
+
+    // Client C also follows client A.
+    workspace_c
+        .update(cx_c, |workspace, cx| {
+            workspace.follow(peer_id_a, cx).unwrap()
+        })
+        .await
+        .unwrap();
+
+    cx_d.foreground().run_until_parked();
+    let active_call_d = cx_d.read(ActiveCall::global);
+    let project_d = client_d.build_remote_project(project_id, cx_d).await;
+    let workspace_d = client_d.build_workspace(&project_d, cx_d).root(cx_d);
+    active_call_d
+        .update(cx_d, |call, cx| call.set_location(Some(&project_d), cx))
+        .await
+        .unwrap();
+    drop(project_d);
+
+    // All clients see that clients B and C are following client A.
+    cx_c.foreground().run_until_parked();
+    for (name, active_call, cx) in [
+        ("A", &active_call_a, &cx_a),
+        ("B", &active_call_b, &cx_b),
+        ("C", &active_call_c, &cx_c),
+        ("D", &active_call_d, &cx_d),
+    ] {
+        active_call.read_with(*cx, |call, cx| {
+            let room = call.room().unwrap().read(cx);
+            assert_eq!(
+                room.followers_for(peer_id_a, project_id),
+                &[peer_id_b, peer_id_c],
+                "checking followers for A as {name}"
+            );
+        });
+    }
+
+    // Client C unfollows client A.
+    workspace_c.update(cx_c, |workspace, cx| {
+        workspace.unfollow(&workspace.active_pane().clone(), cx);
+    });
+
+    // All clients see that clients B is following client A.
+    cx_c.foreground().run_until_parked();
+    for (name, active_call, cx) in [
+        ("A", &active_call_a, &cx_a),
+        ("B", &active_call_b, &cx_b),
+        ("C", &active_call_c, &cx_c),
+        ("D", &active_call_d, &cx_d),
+    ] {
+        active_call.read_with(*cx, |call, cx| {
+            let room = call.room().unwrap().read(cx);
+            assert_eq!(
+                room.followers_for(peer_id_a, project_id),
+                &[peer_id_b],
+                "checking followers for A as {name}"
+            );
+        });
+    }
+
+    // Client C re-follows client A.
+    workspace_c.update(cx_c, |workspace, cx| {
+        workspace.follow(peer_id_a, cx);
+    });
+
+    // All clients see that clients B and C are following client A.
+    cx_c.foreground().run_until_parked();
+    for (name, active_call, cx) in [
+        ("A", &active_call_a, &cx_a),
+        ("B", &active_call_b, &cx_b),
+        ("C", &active_call_c, &cx_c),
+        ("D", &active_call_d, &cx_d),
+    ] {
+        active_call.read_with(*cx, |call, cx| {
+            let room = call.room().unwrap().read(cx);
+            assert_eq!(
+                room.followers_for(peer_id_a, project_id),
+                &[peer_id_b, peer_id_c],
+                "checking followers for A as {name}"
+            );
+        });
+    }
+
+    // Client D follows client C.
+    workspace_d
+        .update(cx_d, |workspace, cx| {
+            workspace.follow(peer_id_c, cx).unwrap()
+        })
+        .await
+        .unwrap();
+
+    // All clients see that D is following C
+    cx_d.foreground().run_until_parked();
+    for (name, active_call, cx) in [
+        ("A", &active_call_a, &cx_a),
+        ("B", &active_call_b, &cx_b),
+        ("C", &active_call_c, &cx_c),
+        ("D", &active_call_d, &cx_d),
+    ] {
+        active_call.read_with(*cx, |call, cx| {
+            let room = call.room().unwrap().read(cx);
+            assert_eq!(
+                room.followers_for(peer_id_c, project_id),
+                &[peer_id_d],
+                "checking followers for C as {name}"
+            );
+        });
+    }
+
+    // Client C closes the project.
+    window_c.remove(cx_c);
+    cx_c.drop_last(workspace_c);
+
+    // Clients A and B see that client B is following A, and client C is not present in the followers.
+    cx_c.foreground().run_until_parked();
+    for (name, active_call, cx) in [("A", &active_call_a, &cx_a), ("B", &active_call_b, &cx_b)] {
+        active_call.read_with(*cx, |call, cx| {
+            let room = call.room().unwrap().read(cx);
+            assert_eq!(
+                room.followers_for(peer_id_a, project_id),
+                &[peer_id_b],
+                "checking followers for A as {name}"
+            );
+        });
+    }
+
+    // All clients see that no-one is following C
+    for (name, active_call, cx) in [
+        ("A", &active_call_a, &cx_a),
+        ("B", &active_call_b, &cx_b),
+        ("C", &active_call_c, &cx_c),
+        ("D", &active_call_d, &cx_d),
+    ] {
+        active_call.read_with(*cx, |call, cx| {
+            let room = call.room().unwrap().read(cx);
+            assert_eq!(
+                room.followers_for(peer_id_c, project_id),
+                &[],
+                "checking followers for C as {name}"
+            );
+        });
+    }
+
+    // When client A activates a different editor, client B does so as well.
+    workspace_a.update(cx_a, |workspace, cx| {
+        workspace.activate_item(&editor_a1, cx)
+    });
+    deterministic.run_until_parked();
+    workspace_b.read_with(cx_b, |workspace, cx| {
+        assert_eq!(workspace.active_item(cx).unwrap().id(), editor_b1.id());
+    });
+
+    // When client A opens a multibuffer, client B does so as well.
+    let multibuffer_a = cx_a.add_model(|cx| {
+        let buffer_a1 = project_a.update(cx, |project, cx| {
+            project
+                .get_open_buffer(&(worktree_id, "1.txt").into(), cx)
+                .unwrap()
+        });
+        let buffer_a2 = project_a.update(cx, |project, cx| {
+            project
+                .get_open_buffer(&(worktree_id, "2.txt").into(), cx)
+                .unwrap()
+        });
+        let mut result = MultiBuffer::new(0);
+        result.push_excerpts(
+            buffer_a1,
+            [ExcerptRange {
+                context: 0..3,
+                primary: None,
+            }],
+            cx,
+        );
+        result.push_excerpts(
+            buffer_a2,
+            [ExcerptRange {
+                context: 4..7,
+                primary: None,
+            }],
+            cx,
+        );
+        result
+    });
+    let multibuffer_editor_a = workspace_a.update(cx_a, |workspace, cx| {
+        let editor =
+            cx.add_view(|cx| Editor::for_multibuffer(multibuffer_a, Some(project_a.clone()), cx));
+        workspace.add_item(Box::new(editor.clone()), cx);
+        editor
+    });
+    deterministic.run_until_parked();
+    let multibuffer_editor_b = workspace_b.read_with(cx_b, |workspace, cx| {
+        workspace
+            .active_item(cx)
+            .unwrap()
+            .downcast::<Editor>()
+            .unwrap()
+    });
+    assert_eq!(
+        multibuffer_editor_a.read_with(cx_a, |editor, cx| editor.text(cx)),
+        multibuffer_editor_b.read_with(cx_b, |editor, cx| editor.text(cx)),
+    );
+
+    // When client A navigates back and forth, client B does so as well.
+    workspace_a
+        .update(cx_a, |workspace, cx| {
+            workspace.go_back(workspace.active_pane().downgrade(), cx)
+        })
+        .await
+        .unwrap();
+    deterministic.run_until_parked();
+    workspace_b.read_with(cx_b, |workspace, cx| {
+        assert_eq!(workspace.active_item(cx).unwrap().id(), editor_b1.id());
+    });
+
+    workspace_a
+        .update(cx_a, |workspace, cx| {
+            workspace.go_back(workspace.active_pane().downgrade(), cx)
+        })
+        .await
+        .unwrap();
+    deterministic.run_until_parked();
+    workspace_b.read_with(cx_b, |workspace, cx| {
+        assert_eq!(workspace.active_item(cx).unwrap().id(), editor_b2.id());
+    });
+
+    workspace_a
+        .update(cx_a, |workspace, cx| {
+            workspace.go_forward(workspace.active_pane().downgrade(), cx)
+        })
+        .await
+        .unwrap();
+    deterministic.run_until_parked();
+    workspace_b.read_with(cx_b, |workspace, cx| {
+        assert_eq!(workspace.active_item(cx).unwrap().id(), editor_b1.id());
+    });
+
+    // Changes to client A's editor are reflected on client B.
+    editor_a1.update(cx_a, |editor, cx| {
+        editor.change_selections(None, cx, |s| s.select_ranges([1..1, 2..2]));
+    });
+    deterministic.run_until_parked();
+    editor_b1.read_with(cx_b, |editor, cx| {
+        assert_eq!(editor.selections.ranges(cx), &[1..1, 2..2]);
+    });
+
+    editor_a1.update(cx_a, |editor, cx| editor.set_text("TWO", cx));
+    deterministic.run_until_parked();
+    editor_b1.read_with(cx_b, |editor, cx| assert_eq!(editor.text(cx), "TWO"));
+
+    editor_a1.update(cx_a, |editor, cx| {
+        editor.change_selections(None, cx, |s| s.select_ranges([3..3]));
+        editor.set_scroll_position(vec2f(0., 100.), cx);
+    });
+    deterministic.run_until_parked();
+    editor_b1.read_with(cx_b, |editor, cx| {
+        assert_eq!(editor.selections.ranges(cx), &[3..3]);
+    });
+
+    // After unfollowing, client B stops receiving updates from client A.
+    workspace_b.update(cx_b, |workspace, cx| {
+        workspace.unfollow(&workspace.active_pane().clone(), cx)
+    });
+    workspace_a.update(cx_a, |workspace, cx| {
+        workspace.activate_item(&editor_a2, cx)
+    });
+    deterministic.run_until_parked();
+    assert_eq!(
+        workspace_b.read_with(cx_b, |workspace, cx| workspace
+            .active_item(cx)
+            .unwrap()
+            .id()),
+        editor_b1.id()
+    );
+
+    // Client A starts following client B.
+    workspace_a
+        .update(cx_a, |workspace, cx| {
+            workspace.follow(peer_id_b, cx).unwrap()
+        })
+        .await
+        .unwrap();
+    assert_eq!(
+        workspace_a.read_with(cx_a, |workspace, _| workspace.leader_for_pane(&pane_a)),
+        Some(peer_id_b)
+    );
+    assert_eq!(
+        workspace_a.read_with(cx_a, |workspace, cx| workspace
+            .active_item(cx)
+            .unwrap()
+            .id()),
+        editor_a1.id()
+    );
+
+    // Client B activates an external window, which causes a new screen-sharing item to be added to the pane.
+    let display = MacOSDisplay::new();
+    active_call_b
+        .update(cx_b, |call, cx| call.set_location(None, cx))
+        .await
+        .unwrap();
+    active_call_b
+        .update(cx_b, |call, cx| {
+            call.room().unwrap().update(cx, |room, cx| {
+                room.set_display_sources(vec![display.clone()]);
+                room.share_screen(cx)
+            })
+        })
+        .await
+        .unwrap();
+    deterministic.run_until_parked();
+    let shared_screen = workspace_a.read_with(cx_a, |workspace, cx| {
+        workspace
+            .active_item(cx)
+            .expect("no active item")
+            .downcast::<SharedScreen>()
+            .expect("active item isn't a shared screen")
+    });
+
+    // Client B activates Zed again, which causes the previous editor to become focused again.
+    active_call_b
+        .update(cx_b, |call, cx| call.set_location(Some(&project_b), cx))
+        .await
+        .unwrap();
+    deterministic.run_until_parked();
+    workspace_a.read_with(cx_a, |workspace, cx| {
+        assert_eq!(workspace.active_item(cx).unwrap().id(), editor_a1.id())
+    });
+
+    // Client B activates a multibuffer that was created by following client A. Client A returns to that multibuffer.
+    workspace_b.update(cx_b, |workspace, cx| {
+        workspace.activate_item(&multibuffer_editor_b, cx)
+    });
+    deterministic.run_until_parked();
+    workspace_a.read_with(cx_a, |workspace, cx| {
+        assert_eq!(
+            workspace.active_item(cx).unwrap().id(),
+            multibuffer_editor_a.id()
+        )
+    });
+
+    // Client B activates a panel, and the previously-opened screen-sharing item gets activated.
+    let panel = window_b.add_view(cx_b, |_| TestPanel::new(DockPosition::Left));
+    workspace_b.update(cx_b, |workspace, cx| {
+        workspace.add_panel(panel, cx);
+        workspace.toggle_panel_focus::<TestPanel>(cx);
+    });
+    deterministic.run_until_parked();
+    assert_eq!(
+        workspace_a.read_with(cx_a, |workspace, cx| workspace
+            .active_item(cx)
+            .unwrap()
+            .id()),
+        shared_screen.id()
+    );
+
+    // Toggling the focus back to the pane causes client A to return to the multibuffer.
+    workspace_b.update(cx_b, |workspace, cx| {
+        workspace.toggle_panel_focus::<TestPanel>(cx);
+    });
+    deterministic.run_until_parked();
+    workspace_a.read_with(cx_a, |workspace, cx| {
+        assert_eq!(
+            workspace.active_item(cx).unwrap().id(),
+            multibuffer_editor_a.id()
+        )
+    });
+
+    // Client B activates an item that doesn't implement following,
+    // so the previously-opened screen-sharing item gets activated.
+    let unfollowable_item = window_b.add_view(cx_b, |_| TestItem::new());
+    workspace_b.update(cx_b, |workspace, cx| {
+        workspace.active_pane().update(cx, |pane, cx| {
+            pane.add_item(Box::new(unfollowable_item), true, true, None, cx)
+        })
+    });
+    deterministic.run_until_parked();
+    assert_eq!(
+        workspace_a.read_with(cx_a, |workspace, cx| workspace
+            .active_item(cx)
+            .unwrap()
+            .id()),
+        shared_screen.id()
+    );
+
+    // Following interrupts when client B disconnects.
+    client_b.disconnect(&cx_b.to_async());
+    deterministic.advance_clock(RECONNECT_TIMEOUT);
+    assert_eq!(
+        workspace_a.read_with(cx_a, |workspace, _| workspace.leader_for_pane(&pane_a)),
+        None
+    );
+}
+
+#[gpui::test]
+async fn test_following_tab_order(
+    deterministic: Arc<Deterministic>,
+    cx_a: &mut TestAppContext,
+    cx_b: &mut TestAppContext,
+) {
+    let mut server = TestServer::start(&deterministic).await;
+    let client_a = server.create_client(cx_a, "user_a").await;
+    let client_b = server.create_client(cx_b, "user_b").await;
+    server
+        .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
+        .await;
+    let active_call_a = cx_a.read(ActiveCall::global);
+    let active_call_b = cx_b.read(ActiveCall::global);
+
+    cx_a.update(editor::init);
+    cx_b.update(editor::init);
+
+    client_a
+        .fs()
+        .insert_tree(
+            "/a",
+            json!({
+                "1.txt": "one",
+                "2.txt": "two",
+                "3.txt": "three",
+            }),
+        )
+        .await;
+    let (project_a, worktree_id) = client_a.build_local_project("/a", cx_a).await;
+    active_call_a
+        .update(cx_a, |call, cx| call.set_location(Some(&project_a), cx))
+        .await
+        .unwrap();
+
+    let project_id = active_call_a
+        .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
+        .await
+        .unwrap();
+    let project_b = client_b.build_remote_project(project_id, cx_b).await;
+    active_call_b
+        .update(cx_b, |call, cx| call.set_location(Some(&project_b), cx))
+        .await
+        .unwrap();
+
+    let workspace_a = client_a.build_workspace(&project_a, cx_a).root(cx_a);
+    let pane_a = workspace_a.read_with(cx_a, |workspace, _| workspace.active_pane().clone());
+
+    let workspace_b = client_b.build_workspace(&project_b, cx_b).root(cx_b);
+    let pane_b = workspace_b.read_with(cx_b, |workspace, _| workspace.active_pane().clone());
+
+    let client_b_id = project_a.read_with(cx_a, |project, _| {
+        project.collaborators().values().next().unwrap().peer_id
+    });
+
+    //Open 1, 3 in that order on client A
+    workspace_a
+        .update(cx_a, |workspace, cx| {
+            workspace.open_path((worktree_id, "1.txt"), None, true, cx)
+        })
+        .await
+        .unwrap();
+    workspace_a
+        .update(cx_a, |workspace, cx| {
+            workspace.open_path((worktree_id, "3.txt"), None, true, cx)
+        })
+        .await
+        .unwrap();
+
+    let pane_paths = |pane: &ViewHandle<workspace::Pane>, cx: &mut TestAppContext| {
+        pane.update(cx, |pane, cx| {
+            pane.items()
+                .map(|item| {
+                    item.project_path(cx)
+                        .unwrap()
+                        .path
+                        .to_str()
+                        .unwrap()
+                        .to_owned()
+                })
+                .collect::<Vec<_>>()
+        })
+    };
+
+    //Verify that the tabs opened in the order we expect
+    assert_eq!(&pane_paths(&pane_a, cx_a), &["1.txt", "3.txt"]);
+
+    //Follow client B as client A
+    workspace_a
+        .update(cx_a, |workspace, cx| {
+            workspace.follow(client_b_id, cx).unwrap()
+        })
+        .await
+        .unwrap();
+
+    //Open just 2 on client B
+    workspace_b
+        .update(cx_b, |workspace, cx| {
+            workspace.open_path((worktree_id, "2.txt"), None, true, cx)
+        })
+        .await
+        .unwrap();
+    deterministic.run_until_parked();
+
+    // Verify that newly opened followed file is at the end
+    assert_eq!(&pane_paths(&pane_a, cx_a), &["1.txt", "3.txt", "2.txt"]);
+
+    //Open just 1 on client B
+    workspace_b
+        .update(cx_b, |workspace, cx| {
+            workspace.open_path((worktree_id, "1.txt"), None, true, cx)
+        })
+        .await
+        .unwrap();
+    assert_eq!(&pane_paths(&pane_b, cx_b), &["2.txt", "1.txt"]);
+    deterministic.run_until_parked();
+
+    // Verify that following into 1 did not reorder
+    assert_eq!(&pane_paths(&pane_a, cx_a), &["1.txt", "3.txt", "2.txt"]);
+}
+
+#[gpui::test(iterations = 10)]
+async fn test_peers_following_each_other(
+    deterministic: Arc<Deterministic>,
+    cx_a: &mut TestAppContext,
+    cx_b: &mut TestAppContext,
+) {
+    deterministic.forbid_parking();
+    let mut server = TestServer::start(&deterministic).await;
+    let client_a = server.create_client(cx_a, "user_a").await;
+    let client_b = server.create_client(cx_b, "user_b").await;
+    server
+        .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
+        .await;
+    let active_call_a = cx_a.read(ActiveCall::global);
+    let active_call_b = cx_b.read(ActiveCall::global);
+
+    cx_a.update(editor::init);
+    cx_b.update(editor::init);
+
+    // Client A shares a project.
+    client_a
+        .fs()
+        .insert_tree(
+            "/a",
+            json!({
+                "1.txt": "one",
+                "2.txt": "two",
+                "3.txt": "three",
+                "4.txt": "four",
+            }),
+        )
+        .await;
+    let (project_a, worktree_id) = client_a.build_local_project("/a", cx_a).await;
+    active_call_a
+        .update(cx_a, |call, cx| call.set_location(Some(&project_a), cx))
+        .await
+        .unwrap();
+    let project_id = active_call_a
+        .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
+        .await
+        .unwrap();
+
+    // Client B joins the project.
+    let project_b = client_b.build_remote_project(project_id, cx_b).await;
+    active_call_b
+        .update(cx_b, |call, cx| call.set_location(Some(&project_b), cx))
+        .await
+        .unwrap();
+
+    // Client A opens some editors.
+    let workspace_a = client_a.build_workspace(&project_a, cx_a).root(cx_a);
+    let pane_a1 = workspace_a.read_with(cx_a, |workspace, _| workspace.active_pane().clone());
+    let _editor_a1 = workspace_a
+        .update(cx_a, |workspace, cx| {
+            workspace.open_path((worktree_id, "1.txt"), None, true, cx)
+        })
+        .await
+        .unwrap()
+        .downcast::<Editor>()
+        .unwrap();
+
+    // Client B opens an editor.
+    let workspace_b = client_b.build_workspace(&project_b, cx_b).root(cx_b);
+    let pane_b1 = workspace_b.read_with(cx_b, |workspace, _| workspace.active_pane().clone());
+    let _editor_b1 = workspace_b
+        .update(cx_b, |workspace, cx| {
+            workspace.open_path((worktree_id, "2.txt"), None, true, cx)
+        })
+        .await
+        .unwrap()
+        .downcast::<Editor>()
+        .unwrap();
+
+    // Clients A and B follow each other in split panes
+    workspace_a.update(cx_a, |workspace, cx| {
+        workspace.split_and_clone(workspace.active_pane().clone(), SplitDirection::Right, cx);
+    });
+    workspace_a
+        .update(cx_a, |workspace, cx| {
+            assert_ne!(*workspace.active_pane(), pane_a1);
+            let leader_id = *project_a.read(cx).collaborators().keys().next().unwrap();
+            workspace.follow(leader_id, cx).unwrap()
+        })
+        .await
+        .unwrap();
+    workspace_b.update(cx_b, |workspace, cx| {
+        workspace.split_and_clone(workspace.active_pane().clone(), SplitDirection::Right, cx);
+    });
+    workspace_b
+        .update(cx_b, |workspace, cx| {
+            assert_ne!(*workspace.active_pane(), pane_b1);
+            let leader_id = *project_b.read(cx).collaborators().keys().next().unwrap();
+            workspace.follow(leader_id, cx).unwrap()
+        })
+        .await
+        .unwrap();
+
+    workspace_a.update(cx_a, |workspace, cx| {
+        workspace.activate_next_pane(cx);
+    });
+    // Wait for focus effects to be fully flushed
+    workspace_a.update(cx_a, |workspace, _| {
+        assert_eq!(*workspace.active_pane(), pane_a1);
+    });
+
+    workspace_a
+        .update(cx_a, |workspace, cx| {
+            workspace.open_path((worktree_id, "3.txt"), None, true, cx)
+        })
+        .await
+        .unwrap();
+    workspace_b.update(cx_b, |workspace, cx| {
+        workspace.activate_next_pane(cx);
+    });
+
+    workspace_b
+        .update(cx_b, |workspace, cx| {
+            assert_eq!(*workspace.active_pane(), pane_b1);
+            workspace.open_path((worktree_id, "4.txt"), None, true, cx)
+        })
+        .await
+        .unwrap();
+    cx_a.foreground().run_until_parked();
+
+    // Ensure leader updates don't change the active pane of followers
+    workspace_a.read_with(cx_a, |workspace, _| {
+        assert_eq!(*workspace.active_pane(), pane_a1);
+    });
+    workspace_b.read_with(cx_b, |workspace, _| {
+        assert_eq!(*workspace.active_pane(), pane_b1);
+    });
+
+    // Ensure peers following each other doesn't cause an infinite loop.
+    assert_eq!(
+        workspace_a.read_with(cx_a, |workspace, cx| workspace
+            .active_item(cx)
+            .unwrap()
+            .project_path(cx)),
+        Some((worktree_id, "3.txt").into())
+    );
+    workspace_a.update(cx_a, |workspace, cx| {
+        assert_eq!(
+            workspace.active_item(cx).unwrap().project_path(cx),
+            Some((worktree_id, "3.txt").into())
+        );
+        workspace.activate_next_pane(cx);
+    });
+
+    workspace_a.update(cx_a, |workspace, cx| {
+        assert_eq!(
+            workspace.active_item(cx).unwrap().project_path(cx),
+            Some((worktree_id, "4.txt").into())
+        );
+    });
+
+    workspace_b.update(cx_b, |workspace, cx| {
+        assert_eq!(
+            workspace.active_item(cx).unwrap().project_path(cx),
+            Some((worktree_id, "4.txt").into())
+        );
+        workspace.activate_next_pane(cx);
+    });
+
+    workspace_b.update(cx_b, |workspace, cx| {
+        assert_eq!(
+            workspace.active_item(cx).unwrap().project_path(cx),
+            Some((worktree_id, "3.txt").into())
+        );
+    });
+}
+
+#[gpui::test(iterations = 10)]
+async fn test_auto_unfollowing(
+    deterministic: Arc<Deterministic>,
+    cx_a: &mut TestAppContext,
+    cx_b: &mut TestAppContext,
+) {
+    deterministic.forbid_parking();
+
+    // 2 clients connect to a server.
+    let mut server = TestServer::start(&deterministic).await;
+    let client_a = server.create_client(cx_a, "user_a").await;
+    let client_b = server.create_client(cx_b, "user_b").await;
+    server
+        .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
+        .await;
+    let active_call_a = cx_a.read(ActiveCall::global);
+    let active_call_b = cx_b.read(ActiveCall::global);
+
+    cx_a.update(editor::init);
+    cx_b.update(editor::init);
+
+    // Client A shares a project.
+    client_a
+        .fs()
+        .insert_tree(
+            "/a",
+            json!({
+                "1.txt": "one",
+                "2.txt": "two",
+                "3.txt": "three",
+            }),
+        )
+        .await;
+    let (project_a, worktree_id) = client_a.build_local_project("/a", cx_a).await;
+    active_call_a
+        .update(cx_a, |call, cx| call.set_location(Some(&project_a), cx))
+        .await
+        .unwrap();
+
+    let project_id = active_call_a
+        .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
+        .await
+        .unwrap();
+    let project_b = client_b.build_remote_project(project_id, cx_b).await;
+    active_call_b
+        .update(cx_b, |call, cx| call.set_location(Some(&project_b), cx))
+        .await
+        .unwrap();
+
+    // Client A opens some editors.
+    let workspace_a = client_a.build_workspace(&project_a, cx_a).root(cx_a);
+    let _editor_a1 = workspace_a
+        .update(cx_a, |workspace, cx| {
+            workspace.open_path((worktree_id, "1.txt"), None, true, cx)
+        })
+        .await
+        .unwrap()
+        .downcast::<Editor>()
+        .unwrap();
+
+    // Client B starts following client A.
+    let workspace_b = client_b.build_workspace(&project_b, cx_b).root(cx_b);
+    let pane_b = workspace_b.read_with(cx_b, |workspace, _| workspace.active_pane().clone());
+    let leader_id = project_b.read_with(cx_b, |project, _| {
+        project.collaborators().values().next().unwrap().peer_id
+    });
+    workspace_b
+        .update(cx_b, |workspace, cx| {
+            workspace.follow(leader_id, cx).unwrap()
+        })
+        .await
+        .unwrap();
+    assert_eq!(
+        workspace_b.read_with(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)),
+        Some(leader_id)
+    );
+    let editor_b2 = workspace_b.read_with(cx_b, |workspace, cx| {
+        workspace
+            .active_item(cx)
+            .unwrap()
+            .downcast::<Editor>()
+            .unwrap()
+    });
+
+    // When client B moves, it automatically stops following client A.
+    editor_b2.update(cx_b, |editor, cx| editor.move_right(&editor::MoveRight, cx));
+    assert_eq!(
+        workspace_b.read_with(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)),
+        None
+    );
+
+    workspace_b
+        .update(cx_b, |workspace, cx| {
+            workspace.follow(leader_id, cx).unwrap()
+        })
+        .await
+        .unwrap();
+    assert_eq!(
+        workspace_b.read_with(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)),
+        Some(leader_id)
+    );
+
+    // When client B edits, it automatically stops following client A.
+    editor_b2.update(cx_b, |editor, cx| editor.insert("X", cx));
+    assert_eq!(
+        workspace_b.read_with(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)),
+        None
+    );
+
+    workspace_b
+        .update(cx_b, |workspace, cx| {
+            workspace.follow(leader_id, cx).unwrap()
+        })
+        .await
+        .unwrap();
+    assert_eq!(
+        workspace_b.read_with(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)),
+        Some(leader_id)
+    );
+
+    // When client B scrolls, it automatically stops following client A.
+    editor_b2.update(cx_b, |editor, cx| {
+        editor.set_scroll_position(vec2f(0., 3.), cx)
+    });
+    assert_eq!(
+        workspace_b.read_with(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)),
+        None
+    );
+
+    workspace_b
+        .update(cx_b, |workspace, cx| {
+            workspace.follow(leader_id, cx).unwrap()
+        })
+        .await
+        .unwrap();
+    assert_eq!(
+        workspace_b.read_with(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)),
+        Some(leader_id)
+    );
+
+    // When client B activates a different pane, it continues following client A in the original pane.
+    workspace_b.update(cx_b, |workspace, cx| {
+        workspace.split_and_clone(pane_b.clone(), SplitDirection::Right, cx)
+    });
+    assert_eq!(
+        workspace_b.read_with(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)),
+        Some(leader_id)
+    );
+
+    workspace_b.update(cx_b, |workspace, cx| workspace.activate_next_pane(cx));
+    assert_eq!(
+        workspace_b.read_with(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)),
+        Some(leader_id)
+    );
+
+    // When client B activates a different item in the original pane, it automatically stops following client A.
+    workspace_b
+        .update(cx_b, |workspace, cx| {
+            workspace.open_path((worktree_id, "2.txt"), None, true, cx)
+        })
+        .await
+        .unwrap();
+    assert_eq!(
+        workspace_b.read_with(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)),
+        None
+    );
+}
+
+#[gpui::test(iterations = 10)]
+async fn test_peers_simultaneously_following_each_other(
+    deterministic: Arc<Deterministic>,
+    cx_a: &mut TestAppContext,
+    cx_b: &mut TestAppContext,
+) {
+    deterministic.forbid_parking();
+
+    let mut server = TestServer::start(&deterministic).await;
+    let client_a = server.create_client(cx_a, "user_a").await;
+    let client_b = server.create_client(cx_b, "user_b").await;
+    server
+        .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
+        .await;
+    let active_call_a = cx_a.read(ActiveCall::global);
+
+    cx_a.update(editor::init);
+    cx_b.update(editor::init);
+
+    client_a.fs().insert_tree("/a", json!({})).await;
+    let (project_a, _) = client_a.build_local_project("/a", cx_a).await;
+    let workspace_a = client_a.build_workspace(&project_a, cx_a).root(cx_a);
+    let project_id = active_call_a
+        .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
+        .await
+        .unwrap();
+
+    let project_b = client_b.build_remote_project(project_id, cx_b).await;
+    let workspace_b = client_b.build_workspace(&project_b, cx_b).root(cx_b);
+
+    deterministic.run_until_parked();
+    let client_a_id = project_b.read_with(cx_b, |project, _| {
+        project.collaborators().values().next().unwrap().peer_id
+    });
+    let client_b_id = project_a.read_with(cx_a, |project, _| {
+        project.collaborators().values().next().unwrap().peer_id
+    });
+
+    let a_follow_b = workspace_a.update(cx_a, |workspace, cx| {
+        workspace.follow(client_b_id, cx).unwrap()
+    });
+    let b_follow_a = workspace_b.update(cx_b, |workspace, cx| {
+        workspace.follow(client_a_id, cx).unwrap()
+    });
+
+    futures::try_join!(a_follow_b, b_follow_a).unwrap();
+    workspace_a.read_with(cx_a, |workspace, _| {
+        assert_eq!(
+            workspace.leader_for_pane(workspace.active_pane()),
+            Some(client_b_id)
+        );
+    });
+    workspace_b.read_with(cx_b, |workspace, _| {
+        assert_eq!(
+            workspace.leader_for_pane(workspace.active_pane()),
+            Some(client_a_id)
+        );
+    });
+}
+
+fn visible_push_notifications(
+    cx: &mut TestAppContext,
+) -> Vec<gpui::ViewHandle<ProjectSharedNotification>> {
+    let mut ret = Vec::new();
+    for window in cx.windows() {
+        window.read_with(cx, |window| {
+            if let Some(handle) = window
+                .root_view()
+                .clone()
+                .downcast::<ProjectSharedNotification>()
+            {
+                ret.push(handle)
+            }
+        });
+    }
+    ret
+}
+
+#[gpui::test(iterations = 10)]
+async fn test_following_across_workspaces(
+    deterministic: Arc<Deterministic>,
+    cx_a: &mut TestAppContext,
+    cx_b: &mut TestAppContext,
+) {
+    // a and b join a channel/call
+    // a shares project 1
+    // b shares project 2
+    //
+    // b follows a: causes project 2 to be joined, and b to follow a.
+    // b opens a different file in project 2, a follows b
+    // b opens a different file in project 1, a cannot follow b
+    // b shares the project, a joins the project and follows b
+    deterministic.forbid_parking();
+    let mut server = TestServer::start(&deterministic).await;
+    let client_a = server.create_client(cx_a, "user_a").await;
+    let client_b = server.create_client(cx_b, "user_b").await;
+    cx_a.update(editor::init);
+    cx_b.update(editor::init);
+
+    client_a
+        .fs()
+        .insert_tree(
+            "/a",
+            json!({
+                "w.rs": "",
+                "x.rs": "",
+            }),
+        )
+        .await;
+
+    client_b
+        .fs()
+        .insert_tree(
+            "/b",
+            json!({
+                "y.rs": "",
+                "z.rs": "",
+            }),
+        )
+        .await;
+
+    server
+        .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
+        .await;
+    let active_call_a = cx_a.read(ActiveCall::global);
+    let active_call_b = cx_b.read(ActiveCall::global);
+
+    let (project_a, worktree_id_a) = client_a.build_local_project("/a", cx_a).await;
+    let (project_b, worktree_id_b) = client_b.build_local_project("/b", cx_b).await;
+
+    let workspace_a = client_a.build_workspace(&project_a, cx_a).root(cx_a);
+    let workspace_b = client_b.build_workspace(&project_b, cx_b).root(cx_b);
+
+    cx_a.update(|cx| collab_ui::init(&client_a.app_state, cx));
+    cx_b.update(|cx| collab_ui::init(&client_b.app_state, cx));
+
+    active_call_a
+        .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
+        .await
+        .unwrap();
+
+    active_call_a
+        .update(cx_a, |call, cx| call.set_location(Some(&project_a), cx))
+        .await
+        .unwrap();
+    active_call_b
+        .update(cx_b, |call, cx| call.set_location(Some(&project_b), cx))
+        .await
+        .unwrap();
+
+    workspace_a
+        .update(cx_a, |workspace, cx| {
+            workspace.open_path((worktree_id_a, "w.rs"), None, true, cx)
+        })
+        .await
+        .unwrap();
+
+    deterministic.run_until_parked();
+    assert_eq!(visible_push_notifications(cx_b).len(), 1);
+
+    workspace_b.update(cx_b, |workspace, cx| {
+        workspace
+            .follow(client_a.peer_id().unwrap(), cx)
+            .unwrap()
+            .detach()
+    });
+
+    deterministic.run_until_parked();
+    let workspace_b_project_a = cx_b
+        .windows()
+        .iter()
+        .max_by_key(|window| window.id())
+        .unwrap()
+        .downcast::<Workspace>()
+        .unwrap()
+        .root(cx_b);
+
+    // assert that b is following a in project a in w.rs
+    workspace_b_project_a.update(cx_b, |workspace, cx| {
+        assert!(workspace.is_being_followed(client_a.peer_id().unwrap()));
+        assert_eq!(
+            client_a.peer_id(),
+            workspace.leader_for_pane(workspace.active_pane())
+        );
+        let item = workspace.active_item(cx).unwrap();
+        assert_eq!(item.tab_description(0, cx).unwrap(), Cow::Borrowed("w.rs"));
+    });
+
+    // TODO: in app code, this would be done by the collab_ui.
+    active_call_b
+        .update(cx_b, |call, cx| {
+            let project = workspace_b_project_a.read(cx).project().clone();
+            call.set_location(Some(&project), cx)
+        })
+        .await
+        .unwrap();
+
+    // assert that there are no share notifications open
+    assert_eq!(visible_push_notifications(cx_b).len(), 0);
+
+    // b moves to x.rs in a's project, and a follows
+    workspace_b_project_a
+        .update(cx_b, |workspace, cx| {
+            workspace.open_path((worktree_id_a, "x.rs"), None, true, cx)
+        })
+        .await
+        .unwrap();
+
+    deterministic.run_until_parked();
+    workspace_b_project_a.update(cx_b, |workspace, cx| {
+        let item = workspace.active_item(cx).unwrap();
+        assert_eq!(item.tab_description(0, cx).unwrap(), Cow::Borrowed("x.rs"));
+    });
+
+    workspace_a.update(cx_a, |workspace, cx| {
+        workspace
+            .follow(client_b.peer_id().unwrap(), cx)
+            .unwrap()
+            .detach()
+    });
+
+    deterministic.run_until_parked();
+    workspace_a.update(cx_a, |workspace, cx| {
+        assert!(workspace.is_being_followed(client_b.peer_id().unwrap()));
+        assert_eq!(
+            client_b.peer_id(),
+            workspace.leader_for_pane(workspace.active_pane())
+        );
+        let item = workspace.active_pane().read(cx).active_item().unwrap();
+        assert_eq!(item.tab_description(0, cx).unwrap(), Cow::Borrowed("x.rs"));
+    });
+
+    // b moves to y.rs in b's project, a is still following but can't yet see
+    workspace_b
+        .update(cx_b, |workspace, cx| {
+            workspace.open_path((worktree_id_b, "y.rs"), None, true, cx)
+        })
+        .await
+        .unwrap();
+
+    // TODO: in app code, this would be done by the collab_ui.
+    active_call_b
+        .update(cx_b, |call, cx| {
+            let project = workspace_b.read(cx).project().clone();
+            call.set_location(Some(&project), cx)
+        })
+        .await
+        .unwrap();
+
+    let project_b_id = active_call_b
+        .update(cx_b, |call, cx| call.share_project(project_b.clone(), cx))
+        .await
+        .unwrap();
+
+    deterministic.run_until_parked();
+    assert_eq!(visible_push_notifications(cx_a).len(), 1);
+    cx_a.update(|cx| {
+        workspace::join_remote_project(
+            project_b_id,
+            client_b.user_id().unwrap(),
+            client_a.app_state.clone(),
+            cx,
+        )
+    })
+    .await
+    .unwrap();
+
+    deterministic.run_until_parked();
+
+    assert_eq!(visible_push_notifications(cx_a).len(), 0);
+    let workspace_a_project_b = cx_a
+        .windows()
+        .iter()
+        .max_by_key(|window| window.id())
+        .unwrap()
+        .downcast::<Workspace>()
+        .unwrap()
+        .root(cx_a);
+
+    workspace_a_project_b.update(cx_a, |workspace, cx| {
+        assert_eq!(workspace.project().read(cx).remote_id(), Some(project_b_id));
+        assert!(workspace.is_being_followed(client_b.peer_id().unwrap()));
+        assert_eq!(
+            client_b.peer_id(),
+            workspace.leader_for_pane(workspace.active_pane())
+        );
+        let item = workspace.active_item(cx).unwrap();
+        assert_eq!(item.tab_description(0, cx).unwrap(), Cow::Borrowed("y.rs"));
+    });
+}

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

@@ -7,14 +7,11 @@ use client::{User, RECEIVE_TIMEOUT};
 use collections::{HashMap, HashSet};
 use editor::{
     test::editor_test_context::EditorTestContext, ConfirmCodeAction, ConfirmCompletion,
-    ConfirmRename, Editor, ExcerptRange, MultiBuffer, Redo, Rename, ToggleCodeActions, Undo,
+    ConfirmRename, Editor, Redo, Rename, ToggleCodeActions, Undo,
 };
 use fs::{repository::GitFileStatus, FakeFs, Fs as _, RemoveOptions};
 use futures::StreamExt as _;
-use gpui::{
-    executor::Deterministic, geometry::vector::vec2f, test::EmptyView, AppContext, ModelHandle,
-    TestAppContext, ViewHandle,
-};
+use gpui::{executor::Deterministic, test::EmptyView, AppContext, ModelHandle, TestAppContext};
 use indoc::indoc;
 use language::{
     language_settings::{AllLanguageSettings, Formatter, InlayHintSettings},
@@ -38,12 +35,7 @@ use std::{
     },
 };
 use unindent::Unindent as _;
-use workspace::{
-    dock::{test::TestPanel, DockPosition},
-    item::{test::TestItem, ItemHandle as _},
-    shared_screen::SharedScreen,
-    SplitDirection, Workspace,
-};
+use workspace::Workspace;
 
 #[ctor::ctor]
 fn init_logger() {
@@ -6388,455 +6380,49 @@ async fn test_contact_requests(
 }
 
 #[gpui::test(iterations = 10)]
-async fn test_basic_following(
+async fn test_join_call_after_screen_was_shared(
     deterministic: Arc<Deterministic>,
     cx_a: &mut TestAppContext,
     cx_b: &mut TestAppContext,
-    cx_c: &mut TestAppContext,
-    cx_d: &mut TestAppContext,
 ) {
     deterministic.forbid_parking();
-
     let mut server = TestServer::start(&deterministic).await;
+
     let client_a = server.create_client(cx_a, "user_a").await;
     let client_b = server.create_client(cx_b, "user_b").await;
-    let client_c = server.create_client(cx_c, "user_c").await;
-    let client_d = server.create_client(cx_d, "user_d").await;
     server
-        .create_room(&mut [
-            (&client_a, cx_a),
-            (&client_b, cx_b),
-            (&client_c, cx_c),
-            (&client_d, cx_d),
-        ])
+        .make_contacts(&mut [(&client_a, cx_a), (&client_b, cx_b)])
         .await;
+
     let active_call_a = cx_a.read(ActiveCall::global);
     let active_call_b = cx_b.read(ActiveCall::global);
 
-    cx_a.update(editor::init);
-    cx_b.update(editor::init);
-
-    client_a
-        .fs()
-        .insert_tree(
-            "/a",
-            json!({
-                "1.txt": "one\none\none",
-                "2.txt": "two\ntwo\ntwo",
-                "3.txt": "three\nthree\nthree",
-            }),
-        )
-        .await;
-    let (project_a, worktree_id) = client_a.build_local_project("/a", cx_a).await;
+    // Call users B and C from client A.
     active_call_a
-        .update(cx_a, |call, cx| call.set_location(Some(&project_a), cx))
-        .await
-        .unwrap();
-
-    let project_id = active_call_a
-        .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
-        .await
-        .unwrap();
-    let project_b = client_b.build_remote_project(project_id, cx_b).await;
-    active_call_b
-        .update(cx_b, |call, cx| call.set_location(Some(&project_b), cx))
-        .await
-        .unwrap();
-
-    let window_a = client_a.build_workspace(&project_a, cx_a);
-    let workspace_a = window_a.root(cx_a);
-    let window_b = client_b.build_workspace(&project_b, cx_b);
-    let workspace_b = window_b.root(cx_b);
-
-    // Client A opens some editors.
-    let pane_a = workspace_a.read_with(cx_a, |workspace, _| workspace.active_pane().clone());
-    let editor_a1 = workspace_a
-        .update(cx_a, |workspace, cx| {
-            workspace.open_path((worktree_id, "1.txt"), None, true, cx)
-        })
-        .await
-        .unwrap()
-        .downcast::<Editor>()
-        .unwrap();
-    let editor_a2 = workspace_a
-        .update(cx_a, |workspace, cx| {
-            workspace.open_path((worktree_id, "2.txt"), None, true, cx)
-        })
-        .await
-        .unwrap()
-        .downcast::<Editor>()
-        .unwrap();
-
-    // Client B opens an editor.
-    let editor_b1 = workspace_b
-        .update(cx_b, |workspace, cx| {
-            workspace.open_path((worktree_id, "1.txt"), None, true, cx)
-        })
-        .await
-        .unwrap()
-        .downcast::<Editor>()
-        .unwrap();
-
-    let peer_id_a = client_a.peer_id().unwrap();
-    let peer_id_b = client_b.peer_id().unwrap();
-    let peer_id_c = client_c.peer_id().unwrap();
-    let peer_id_d = client_d.peer_id().unwrap();
-
-    // Client A updates their selections in those editors
-    editor_a1.update(cx_a, |editor, cx| {
-        editor.handle_input("a", cx);
-        editor.handle_input("b", cx);
-        editor.handle_input("c", cx);
-        editor.select_left(&Default::default(), cx);
-        assert_eq!(editor.selections.ranges(cx), vec![3..2]);
-    });
-    editor_a2.update(cx_a, |editor, cx| {
-        editor.handle_input("d", cx);
-        editor.handle_input("e", cx);
-        editor.select_left(&Default::default(), cx);
-        assert_eq!(editor.selections.ranges(cx), vec![2..1]);
-    });
-
-    // When client B starts following client A, all visible view states are replicated to client B.
-    workspace_b
-        .update(cx_b, |workspace, cx| {
-            workspace.toggle_follow(peer_id_a, cx).unwrap()
-        })
-        .await
-        .unwrap();
-
-    cx_c.foreground().run_until_parked();
-    let editor_b2 = workspace_b.read_with(cx_b, |workspace, cx| {
-        workspace
-            .active_item(cx)
-            .unwrap()
-            .downcast::<Editor>()
-            .unwrap()
-    });
-    assert_eq!(
-        cx_b.read(|cx| editor_b2.project_path(cx)),
-        Some((worktree_id, "2.txt").into())
-    );
-    assert_eq!(
-        editor_b2.read_with(cx_b, |editor, cx| editor.selections.ranges(cx)),
-        vec![2..1]
-    );
-    assert_eq!(
-        editor_b1.read_with(cx_b, |editor, cx| editor.selections.ranges(cx)),
-        vec![3..2]
-    );
-
-    cx_c.foreground().run_until_parked();
-    let active_call_c = cx_c.read(ActiveCall::global);
-    let project_c = client_c.build_remote_project(project_id, cx_c).await;
-    let window_c = client_c.build_workspace(&project_c, cx_c);
-    let workspace_c = window_c.root(cx_c);
-    active_call_c
-        .update(cx_c, |call, cx| call.set_location(Some(&project_c), cx))
-        .await
-        .unwrap();
-    drop(project_c);
-
-    // Client C also follows client A.
-    workspace_c
-        .update(cx_c, |workspace, cx| {
-            workspace.toggle_follow(peer_id_a, cx).unwrap()
-        })
-        .await
-        .unwrap();
-
-    cx_d.foreground().run_until_parked();
-    let active_call_d = cx_d.read(ActiveCall::global);
-    let project_d = client_d.build_remote_project(project_id, cx_d).await;
-    let workspace_d = client_d.build_workspace(&project_d, cx_d).root(cx_d);
-    active_call_d
-        .update(cx_d, |call, cx| call.set_location(Some(&project_d), cx))
-        .await
-        .unwrap();
-    drop(project_d);
-
-    // All clients see that clients B and C are following client A.
-    cx_c.foreground().run_until_parked();
-    for (name, active_call, cx) in [
-        ("A", &active_call_a, &cx_a),
-        ("B", &active_call_b, &cx_b),
-        ("C", &active_call_c, &cx_c),
-        ("D", &active_call_d, &cx_d),
-    ] {
-        active_call.read_with(*cx, |call, cx| {
-            let room = call.room().unwrap().read(cx);
-            assert_eq!(
-                room.followers_for(peer_id_a, project_id),
-                &[peer_id_b, peer_id_c],
-                "checking followers for A as {name}"
-            );
-        });
-    }
-
-    // Client C unfollows client A.
-    workspace_c.update(cx_c, |workspace, cx| {
-        workspace.toggle_follow(peer_id_a, cx);
-    });
-
-    // All clients see that clients B is following client A.
-    cx_c.foreground().run_until_parked();
-    for (name, active_call, cx) in [
-        ("A", &active_call_a, &cx_a),
-        ("B", &active_call_b, &cx_b),
-        ("C", &active_call_c, &cx_c),
-        ("D", &active_call_d, &cx_d),
-    ] {
-        active_call.read_with(*cx, |call, cx| {
-            let room = call.room().unwrap().read(cx);
-            assert_eq!(
-                room.followers_for(peer_id_a, project_id),
-                &[peer_id_b],
-                "checking followers for A as {name}"
-            );
-        });
-    }
-
-    // Client C re-follows client A.
-    workspace_c.update(cx_c, |workspace, cx| {
-        workspace.toggle_follow(peer_id_a, cx);
-    });
-
-    // All clients see that clients B and C are following client A.
-    cx_c.foreground().run_until_parked();
-    for (name, active_call, cx) in [
-        ("A", &active_call_a, &cx_a),
-        ("B", &active_call_b, &cx_b),
-        ("C", &active_call_c, &cx_c),
-        ("D", &active_call_d, &cx_d),
-    ] {
-        active_call.read_with(*cx, |call, cx| {
-            let room = call.room().unwrap().read(cx);
-            assert_eq!(
-                room.followers_for(peer_id_a, project_id),
-                &[peer_id_b, peer_id_c],
-                "checking followers for A as {name}"
-            );
-        });
-    }
-
-    // Client D follows client C.
-    workspace_d
-        .update(cx_d, |workspace, cx| {
-            workspace.toggle_follow(peer_id_c, cx).unwrap()
-        })
-        .await
-        .unwrap();
-
-    // All clients see that D is following C
-    cx_d.foreground().run_until_parked();
-    for (name, active_call, cx) in [
-        ("A", &active_call_a, &cx_a),
-        ("B", &active_call_b, &cx_b),
-        ("C", &active_call_c, &cx_c),
-        ("D", &active_call_d, &cx_d),
-    ] {
-        active_call.read_with(*cx, |call, cx| {
-            let room = call.room().unwrap().read(cx);
-            assert_eq!(
-                room.followers_for(peer_id_c, project_id),
-                &[peer_id_d],
-                "checking followers for C as {name}"
-            );
-        });
-    }
-
-    // Client C closes the project.
-    window_c.remove(cx_c);
-    cx_c.drop_last(workspace_c);
-
-    // Clients A and B see that client B is following A, and client C is not present in the followers.
-    cx_c.foreground().run_until_parked();
-    for (name, active_call, cx) in [("A", &active_call_a, &cx_a), ("B", &active_call_b, &cx_b)] {
-        active_call.read_with(*cx, |call, cx| {
-            let room = call.room().unwrap().read(cx);
-            assert_eq!(
-                room.followers_for(peer_id_a, project_id),
-                &[peer_id_b],
-                "checking followers for A as {name}"
-            );
-        });
-    }
-
-    // All clients see that no-one is following C
-    for (name, active_call, cx) in [
-        ("A", &active_call_a, &cx_a),
-        ("B", &active_call_b, &cx_b),
-        ("C", &active_call_c, &cx_c),
-        ("D", &active_call_d, &cx_d),
-    ] {
-        active_call.read_with(*cx, |call, cx| {
-            let room = call.room().unwrap().read(cx);
-            assert_eq!(
-                room.followers_for(peer_id_c, project_id),
-                &[],
-                "checking followers for C as {name}"
-            );
-        });
-    }
-
-    // When client A activates a different editor, client B does so as well.
-    workspace_a.update(cx_a, |workspace, cx| {
-        workspace.activate_item(&editor_a1, cx)
-    });
-    deterministic.run_until_parked();
-    workspace_b.read_with(cx_b, |workspace, cx| {
-        assert_eq!(workspace.active_item(cx).unwrap().id(), editor_b1.id());
-    });
-
-    // When client A opens a multibuffer, client B does so as well.
-    let multibuffer_a = cx_a.add_model(|cx| {
-        let buffer_a1 = project_a.update(cx, |project, cx| {
-            project
-                .get_open_buffer(&(worktree_id, "1.txt").into(), cx)
-                .unwrap()
-        });
-        let buffer_a2 = project_a.update(cx, |project, cx| {
-            project
-                .get_open_buffer(&(worktree_id, "2.txt").into(), cx)
-                .unwrap()
-        });
-        let mut result = MultiBuffer::new(0);
-        result.push_excerpts(
-            buffer_a1,
-            [ExcerptRange {
-                context: 0..3,
-                primary: None,
-            }],
-            cx,
-        );
-        result.push_excerpts(
-            buffer_a2,
-            [ExcerptRange {
-                context: 4..7,
-                primary: None,
-            }],
-            cx,
-        );
-        result
-    });
-    let multibuffer_editor_a = workspace_a.update(cx_a, |workspace, cx| {
-        let editor =
-            cx.add_view(|cx| Editor::for_multibuffer(multibuffer_a, Some(project_a.clone()), cx));
-        workspace.add_item(Box::new(editor.clone()), cx);
-        editor
-    });
-    deterministic.run_until_parked();
-    let multibuffer_editor_b = workspace_b.read_with(cx_b, |workspace, cx| {
-        workspace
-            .active_item(cx)
-            .unwrap()
-            .downcast::<Editor>()
-            .unwrap()
-    });
-    assert_eq!(
-        multibuffer_editor_a.read_with(cx_a, |editor, cx| editor.text(cx)),
-        multibuffer_editor_b.read_with(cx_b, |editor, cx| editor.text(cx)),
-    );
-
-    // When client A navigates back and forth, client B does so as well.
-    workspace_a
-        .update(cx_a, |workspace, cx| {
-            workspace.go_back(workspace.active_pane().downgrade(), cx)
-        })
-        .await
-        .unwrap();
-    deterministic.run_until_parked();
-    workspace_b.read_with(cx_b, |workspace, cx| {
-        assert_eq!(workspace.active_item(cx).unwrap().id(), editor_b1.id());
-    });
-
-    workspace_a
-        .update(cx_a, |workspace, cx| {
-            workspace.go_back(workspace.active_pane().downgrade(), cx)
-        })
-        .await
-        .unwrap();
-    deterministic.run_until_parked();
-    workspace_b.read_with(cx_b, |workspace, cx| {
-        assert_eq!(workspace.active_item(cx).unwrap().id(), editor_b2.id());
-    });
-
-    workspace_a
-        .update(cx_a, |workspace, cx| {
-            workspace.go_forward(workspace.active_pane().downgrade(), cx)
+        .update(cx_a, |call, cx| {
+            call.invite(client_b.user_id().unwrap(), None, cx)
         })
         .await
         .unwrap();
-    deterministic.run_until_parked();
-    workspace_b.read_with(cx_b, |workspace, cx| {
-        assert_eq!(workspace.active_item(cx).unwrap().id(), editor_b1.id());
-    });
-
-    // Changes to client A's editor are reflected on client B.
-    editor_a1.update(cx_a, |editor, cx| {
-        editor.change_selections(None, cx, |s| s.select_ranges([1..1, 2..2]));
-    });
-    deterministic.run_until_parked();
-    editor_b1.read_with(cx_b, |editor, cx| {
-        assert_eq!(editor.selections.ranges(cx), &[1..1, 2..2]);
-    });
-
-    editor_a1.update(cx_a, |editor, cx| editor.set_text("TWO", cx));
-    deterministic.run_until_parked();
-    editor_b1.read_with(cx_b, |editor, cx| assert_eq!(editor.text(cx), "TWO"));
-
-    editor_a1.update(cx_a, |editor, cx| {
-        editor.change_selections(None, cx, |s| s.select_ranges([3..3]));
-        editor.set_scroll_position(vec2f(0., 100.), cx);
-    });
-    deterministic.run_until_parked();
-    editor_b1.read_with(cx_b, |editor, cx| {
-        assert_eq!(editor.selections.ranges(cx), &[3..3]);
-    });
-
-    // After unfollowing, client B stops receiving updates from client A.
-    workspace_b.update(cx_b, |workspace, cx| {
-        workspace.unfollow(&workspace.active_pane().clone(), cx)
-    });
-    workspace_a.update(cx_a, |workspace, cx| {
-        workspace.activate_item(&editor_a2, cx)
-    });
+    let room_a = active_call_a.read_with(cx_a, |call, _| call.room().unwrap().clone());
     deterministic.run_until_parked();
     assert_eq!(
-        workspace_b.read_with(cx_b, |workspace, cx| workspace
-            .active_item(cx)
-            .unwrap()
-            .id()),
-        editor_b1.id()
+        room_participants(&room_a, cx_a),
+        RoomParticipants {
+            remote: Default::default(),
+            pending: vec!["user_b".to_string()]
+        }
     );
 
-    // Client A starts following client B.
-    workspace_a
-        .update(cx_a, |workspace, cx| {
-            workspace.toggle_follow(peer_id_b, cx).unwrap()
-        })
-        .await
-        .unwrap();
-    assert_eq!(
-        workspace_a.read_with(cx_a, |workspace, _| workspace.leader_for_pane(&pane_a)),
-        Some(peer_id_b)
-    );
-    assert_eq!(
-        workspace_a.read_with(cx_a, |workspace, cx| workspace
-            .active_item(cx)
-            .unwrap()
-            .id()),
-        editor_a1.id()
-    );
+    // User B receives the call.
+    let mut incoming_call_b = active_call_b.read_with(cx_b, |call, _| call.incoming());
+    let call_b = incoming_call_b.next().await.unwrap().unwrap();
+    assert_eq!(call_b.calling_user.github_login, "user_a");
 
-    // Client B activates an external window, which causes a new screen-sharing item to be added to the pane.
+    // User A shares their screen
     let display = MacOSDisplay::new();
-    active_call_b
-        .update(cx_b, |call, cx| call.set_location(None, cx))
-        .await
-        .unwrap();
-    active_call_b
-        .update(cx_b, |call, cx| {
+    active_call_a
+        .update(cx_a, |call, cx| {
             call.room().unwrap().update(cx, |room, cx| {
                 room.set_display_sources(vec![display.clone()]);
                 room.share_screen(cx)
@@ -6844,161 +6430,26 @@ async fn test_basic_following(
         })
         .await
         .unwrap();
-    deterministic.run_until_parked();
-    let shared_screen = workspace_a.read_with(cx_a, |workspace, cx| {
-        workspace
-            .active_item(cx)
-            .expect("no active item")
-            .downcast::<SharedScreen>()
-            .expect("active item isn't a shared screen")
+
+    client_b.user_store().update(cx_b, |user_store, _| {
+        user_store.clear_cache();
     });
 
-    // Client B activates Zed again, which causes the previous editor to become focused again.
+    // User B joins the room
     active_call_b
-        .update(cx_b, |call, cx| call.set_location(Some(&project_b), cx))
+        .update(cx_b, |call, cx| call.accept_incoming(cx))
         .await
         .unwrap();
-    deterministic.run_until_parked();
-    workspace_a.read_with(cx_a, |workspace, cx| {
-        assert_eq!(workspace.active_item(cx).unwrap().id(), editor_a1.id())
-    });
-
-    // Client B activates a multibuffer that was created by following client A. Client A returns to that multibuffer.
-    workspace_b.update(cx_b, |workspace, cx| {
-        workspace.activate_item(&multibuffer_editor_b, cx)
-    });
-    deterministic.run_until_parked();
-    workspace_a.read_with(cx_a, |workspace, cx| {
-        assert_eq!(
-            workspace.active_item(cx).unwrap().id(),
-            multibuffer_editor_a.id()
-        )
-    });
+    let room_b = active_call_b.read_with(cx_b, |call, _| call.room().unwrap().clone());
+    assert!(incoming_call_b.next().await.unwrap().is_none());
 
-    // Client B activates a panel, and the previously-opened screen-sharing item gets activated.
-    let panel = window_b.add_view(cx_b, |_| TestPanel::new(DockPosition::Left));
-    workspace_b.update(cx_b, |workspace, cx| {
-        workspace.add_panel(panel, cx);
-        workspace.toggle_panel_focus::<TestPanel>(cx);
-    });
     deterministic.run_until_parked();
     assert_eq!(
-        workspace_a.read_with(cx_a, |workspace, cx| workspace
-            .active_item(cx)
-            .unwrap()
-            .id()),
-        shared_screen.id()
-    );
-
-    // Toggling the focus back to the pane causes client A to return to the multibuffer.
-    workspace_b.update(cx_b, |workspace, cx| {
-        workspace.toggle_panel_focus::<TestPanel>(cx);
-    });
-    deterministic.run_until_parked();
-    workspace_a.read_with(cx_a, |workspace, cx| {
-        assert_eq!(
-            workspace.active_item(cx).unwrap().id(),
-            multibuffer_editor_a.id()
-        )
-    });
-
-    // Client B activates an item that doesn't implement following,
-    // so the previously-opened screen-sharing item gets activated.
-    let unfollowable_item = window_b.add_view(cx_b, |_| TestItem::new());
-    workspace_b.update(cx_b, |workspace, cx| {
-        workspace.active_pane().update(cx, |pane, cx| {
-            pane.add_item(Box::new(unfollowable_item), true, true, None, cx)
-        })
-    });
-    deterministic.run_until_parked();
-    assert_eq!(
-        workspace_a.read_with(cx_a, |workspace, cx| workspace
-            .active_item(cx)
-            .unwrap()
-            .id()),
-        shared_screen.id()
-    );
-
-    // Following interrupts when client B disconnects.
-    client_b.disconnect(&cx_b.to_async());
-    deterministic.advance_clock(RECONNECT_TIMEOUT);
-    assert_eq!(
-        workspace_a.read_with(cx_a, |workspace, _| workspace.leader_for_pane(&pane_a)),
-        None
-    );
-}
-
-#[gpui::test(iterations = 10)]
-async fn test_join_call_after_screen_was_shared(
-    deterministic: Arc<Deterministic>,
-    cx_a: &mut TestAppContext,
-    cx_b: &mut TestAppContext,
-) {
-    deterministic.forbid_parking();
-    let mut server = TestServer::start(&deterministic).await;
-
-    let client_a = server.create_client(cx_a, "user_a").await;
-    let client_b = server.create_client(cx_b, "user_b").await;
-    server
-        .make_contacts(&mut [(&client_a, cx_a), (&client_b, cx_b)])
-        .await;
-
-    let active_call_a = cx_a.read(ActiveCall::global);
-    let active_call_b = cx_b.read(ActiveCall::global);
-
-    // Call users B and C from client A.
-    active_call_a
-        .update(cx_a, |call, cx| {
-            call.invite(client_b.user_id().unwrap(), None, cx)
-        })
-        .await
-        .unwrap();
-    let room_a = active_call_a.read_with(cx_a, |call, _| call.room().unwrap().clone());
-    deterministic.run_until_parked();
-    assert_eq!(
-        room_participants(&room_a, cx_a),
-        RoomParticipants {
-            remote: Default::default(),
-            pending: vec!["user_b".to_string()]
-        }
-    );
-
-    // User B receives the call.
-    let mut incoming_call_b = active_call_b.read_with(cx_b, |call, _| call.incoming());
-    let call_b = incoming_call_b.next().await.unwrap().unwrap();
-    assert_eq!(call_b.calling_user.github_login, "user_a");
-
-    // User A shares their screen
-    let display = MacOSDisplay::new();
-    active_call_a
-        .update(cx_a, |call, cx| {
-            call.room().unwrap().update(cx, |room, cx| {
-                room.set_display_sources(vec![display.clone()]);
-                room.share_screen(cx)
-            })
-        })
-        .await
-        .unwrap();
-
-    client_b.user_store().update(cx_b, |user_store, _| {
-        user_store.clear_cache();
-    });
-
-    // User B joins the room
-    active_call_b
-        .update(cx_b, |call, cx| call.accept_incoming(cx))
-        .await
-        .unwrap();
-    let room_b = active_call_b.read_with(cx_b, |call, _| call.room().unwrap().clone());
-    assert!(incoming_call_b.next().await.unwrap().is_none());
-
-    deterministic.run_until_parked();
-    assert_eq!(
-        room_participants(&room_a, cx_a),
-        RoomParticipants {
-            remote: vec!["user_b".to_string()],
-            pending: vec![],
-        }
+        room_participants(&room_a, cx_a),
+        RoomParticipants {
+            remote: vec!["user_b".to_string()],
+            pending: vec![],
+        }
     );
     assert_eq!(
         room_participants(&room_b, cx_b),
@@ -7021,526 +6472,6 @@ async fn test_join_call_after_screen_was_shared(
     });
 }
 
-#[gpui::test]
-async fn test_following_tab_order(
-    deterministic: Arc<Deterministic>,
-    cx_a: &mut TestAppContext,
-    cx_b: &mut TestAppContext,
-) {
-    let mut server = TestServer::start(&deterministic).await;
-    let client_a = server.create_client(cx_a, "user_a").await;
-    let client_b = server.create_client(cx_b, "user_b").await;
-    server
-        .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
-        .await;
-    let active_call_a = cx_a.read(ActiveCall::global);
-    let active_call_b = cx_b.read(ActiveCall::global);
-
-    cx_a.update(editor::init);
-    cx_b.update(editor::init);
-
-    client_a
-        .fs()
-        .insert_tree(
-            "/a",
-            json!({
-                "1.txt": "one",
-                "2.txt": "two",
-                "3.txt": "three",
-            }),
-        )
-        .await;
-    let (project_a, worktree_id) = client_a.build_local_project("/a", cx_a).await;
-    active_call_a
-        .update(cx_a, |call, cx| call.set_location(Some(&project_a), cx))
-        .await
-        .unwrap();
-
-    let project_id = active_call_a
-        .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
-        .await
-        .unwrap();
-    let project_b = client_b.build_remote_project(project_id, cx_b).await;
-    active_call_b
-        .update(cx_b, |call, cx| call.set_location(Some(&project_b), cx))
-        .await
-        .unwrap();
-
-    let workspace_a = client_a.build_workspace(&project_a, cx_a).root(cx_a);
-    let pane_a = workspace_a.read_with(cx_a, |workspace, _| workspace.active_pane().clone());
-
-    let workspace_b = client_b.build_workspace(&project_b, cx_b).root(cx_b);
-    let pane_b = workspace_b.read_with(cx_b, |workspace, _| workspace.active_pane().clone());
-
-    let client_b_id = project_a.read_with(cx_a, |project, _| {
-        project.collaborators().values().next().unwrap().peer_id
-    });
-
-    //Open 1, 3 in that order on client A
-    workspace_a
-        .update(cx_a, |workspace, cx| {
-            workspace.open_path((worktree_id, "1.txt"), None, true, cx)
-        })
-        .await
-        .unwrap();
-    workspace_a
-        .update(cx_a, |workspace, cx| {
-            workspace.open_path((worktree_id, "3.txt"), None, true, cx)
-        })
-        .await
-        .unwrap();
-
-    let pane_paths = |pane: &ViewHandle<workspace::Pane>, cx: &mut TestAppContext| {
-        pane.update(cx, |pane, cx| {
-            pane.items()
-                .map(|item| {
-                    item.project_path(cx)
-                        .unwrap()
-                        .path
-                        .to_str()
-                        .unwrap()
-                        .to_owned()
-                })
-                .collect::<Vec<_>>()
-        })
-    };
-
-    //Verify that the tabs opened in the order we expect
-    assert_eq!(&pane_paths(&pane_a, cx_a), &["1.txt", "3.txt"]);
-
-    //Follow client B as client A
-    workspace_a
-        .update(cx_a, |workspace, cx| {
-            workspace.toggle_follow(client_b_id, cx).unwrap()
-        })
-        .await
-        .unwrap();
-
-    //Open just 2 on client B
-    workspace_b
-        .update(cx_b, |workspace, cx| {
-            workspace.open_path((worktree_id, "2.txt"), None, true, cx)
-        })
-        .await
-        .unwrap();
-    deterministic.run_until_parked();
-
-    // Verify that newly opened followed file is at the end
-    assert_eq!(&pane_paths(&pane_a, cx_a), &["1.txt", "3.txt", "2.txt"]);
-
-    //Open just 1 on client B
-    workspace_b
-        .update(cx_b, |workspace, cx| {
-            workspace.open_path((worktree_id, "1.txt"), None, true, cx)
-        })
-        .await
-        .unwrap();
-    assert_eq!(&pane_paths(&pane_b, cx_b), &["2.txt", "1.txt"]);
-    deterministic.run_until_parked();
-
-    // Verify that following into 1 did not reorder
-    assert_eq!(&pane_paths(&pane_a, cx_a), &["1.txt", "3.txt", "2.txt"]);
-}
-
-#[gpui::test(iterations = 10)]
-async fn test_peers_following_each_other(
-    deterministic: Arc<Deterministic>,
-    cx_a: &mut TestAppContext,
-    cx_b: &mut TestAppContext,
-) {
-    deterministic.forbid_parking();
-    let mut server = TestServer::start(&deterministic).await;
-    let client_a = server.create_client(cx_a, "user_a").await;
-    let client_b = server.create_client(cx_b, "user_b").await;
-    server
-        .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
-        .await;
-    let active_call_a = cx_a.read(ActiveCall::global);
-    let active_call_b = cx_b.read(ActiveCall::global);
-
-    cx_a.update(editor::init);
-    cx_b.update(editor::init);
-
-    // Client A shares a project.
-    client_a
-        .fs()
-        .insert_tree(
-            "/a",
-            json!({
-                "1.txt": "one",
-                "2.txt": "two",
-                "3.txt": "three",
-                "4.txt": "four",
-            }),
-        )
-        .await;
-    let (project_a, worktree_id) = client_a.build_local_project("/a", cx_a).await;
-    active_call_a
-        .update(cx_a, |call, cx| call.set_location(Some(&project_a), cx))
-        .await
-        .unwrap();
-    let project_id = active_call_a
-        .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
-        .await
-        .unwrap();
-
-    // Client B joins the project.
-    let project_b = client_b.build_remote_project(project_id, cx_b).await;
-    active_call_b
-        .update(cx_b, |call, cx| call.set_location(Some(&project_b), cx))
-        .await
-        .unwrap();
-
-    // Client A opens some editors.
-    let workspace_a = client_a.build_workspace(&project_a, cx_a).root(cx_a);
-    let pane_a1 = workspace_a.read_with(cx_a, |workspace, _| workspace.active_pane().clone());
-    let _editor_a1 = workspace_a
-        .update(cx_a, |workspace, cx| {
-            workspace.open_path((worktree_id, "1.txt"), None, true, cx)
-        })
-        .await
-        .unwrap()
-        .downcast::<Editor>()
-        .unwrap();
-
-    // Client B opens an editor.
-    let workspace_b = client_b.build_workspace(&project_b, cx_b).root(cx_b);
-    let pane_b1 = workspace_b.read_with(cx_b, |workspace, _| workspace.active_pane().clone());
-    let _editor_b1 = workspace_b
-        .update(cx_b, |workspace, cx| {
-            workspace.open_path((worktree_id, "2.txt"), None, true, cx)
-        })
-        .await
-        .unwrap()
-        .downcast::<Editor>()
-        .unwrap();
-
-    // Clients A and B follow each other in split panes
-    workspace_a.update(cx_a, |workspace, cx| {
-        workspace.split_and_clone(workspace.active_pane().clone(), SplitDirection::Right, cx);
-    });
-    workspace_a
-        .update(cx_a, |workspace, cx| {
-            assert_ne!(*workspace.active_pane(), pane_a1);
-            let leader_id = *project_a.read(cx).collaborators().keys().next().unwrap();
-            workspace.toggle_follow(leader_id, cx).unwrap()
-        })
-        .await
-        .unwrap();
-    workspace_b.update(cx_b, |workspace, cx| {
-        workspace.split_and_clone(workspace.active_pane().clone(), SplitDirection::Right, cx);
-    });
-    workspace_b
-        .update(cx_b, |workspace, cx| {
-            assert_ne!(*workspace.active_pane(), pane_b1);
-            let leader_id = *project_b.read(cx).collaborators().keys().next().unwrap();
-            workspace.toggle_follow(leader_id, cx).unwrap()
-        })
-        .await
-        .unwrap();
-
-    workspace_a.update(cx_a, |workspace, cx| {
-        workspace.activate_next_pane(cx);
-    });
-    // Wait for focus effects to be fully flushed
-    workspace_a.update(cx_a, |workspace, _| {
-        assert_eq!(*workspace.active_pane(), pane_a1);
-    });
-
-    workspace_a
-        .update(cx_a, |workspace, cx| {
-            workspace.open_path((worktree_id, "3.txt"), None, true, cx)
-        })
-        .await
-        .unwrap();
-    workspace_b.update(cx_b, |workspace, cx| {
-        workspace.activate_next_pane(cx);
-    });
-
-    workspace_b
-        .update(cx_b, |workspace, cx| {
-            assert_eq!(*workspace.active_pane(), pane_b1);
-            workspace.open_path((worktree_id, "4.txt"), None, true, cx)
-        })
-        .await
-        .unwrap();
-    cx_a.foreground().run_until_parked();
-
-    // Ensure leader updates don't change the active pane of followers
-    workspace_a.read_with(cx_a, |workspace, _| {
-        assert_eq!(*workspace.active_pane(), pane_a1);
-    });
-    workspace_b.read_with(cx_b, |workspace, _| {
-        assert_eq!(*workspace.active_pane(), pane_b1);
-    });
-
-    // Ensure peers following each other doesn't cause an infinite loop.
-    assert_eq!(
-        workspace_a.read_with(cx_a, |workspace, cx| workspace
-            .active_item(cx)
-            .unwrap()
-            .project_path(cx)),
-        Some((worktree_id, "3.txt").into())
-    );
-    workspace_a.update(cx_a, |workspace, cx| {
-        assert_eq!(
-            workspace.active_item(cx).unwrap().project_path(cx),
-            Some((worktree_id, "3.txt").into())
-        );
-        workspace.activate_next_pane(cx);
-    });
-
-    workspace_a.update(cx_a, |workspace, cx| {
-        assert_eq!(
-            workspace.active_item(cx).unwrap().project_path(cx),
-            Some((worktree_id, "4.txt").into())
-        );
-    });
-
-    workspace_b.update(cx_b, |workspace, cx| {
-        assert_eq!(
-            workspace.active_item(cx).unwrap().project_path(cx),
-            Some((worktree_id, "4.txt").into())
-        );
-        workspace.activate_next_pane(cx);
-    });
-
-    workspace_b.update(cx_b, |workspace, cx| {
-        assert_eq!(
-            workspace.active_item(cx).unwrap().project_path(cx),
-            Some((worktree_id, "3.txt").into())
-        );
-    });
-}
-
-#[gpui::test(iterations = 10)]
-async fn test_auto_unfollowing(
-    deterministic: Arc<Deterministic>,
-    cx_a: &mut TestAppContext,
-    cx_b: &mut TestAppContext,
-) {
-    deterministic.forbid_parking();
-
-    // 2 clients connect to a server.
-    let mut server = TestServer::start(&deterministic).await;
-    let client_a = server.create_client(cx_a, "user_a").await;
-    let client_b = server.create_client(cx_b, "user_b").await;
-    server
-        .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
-        .await;
-    let active_call_a = cx_a.read(ActiveCall::global);
-    let active_call_b = cx_b.read(ActiveCall::global);
-
-    cx_a.update(editor::init);
-    cx_b.update(editor::init);
-
-    // Client A shares a project.
-    client_a
-        .fs()
-        .insert_tree(
-            "/a",
-            json!({
-                "1.txt": "one",
-                "2.txt": "two",
-                "3.txt": "three",
-            }),
-        )
-        .await;
-    let (project_a, worktree_id) = client_a.build_local_project("/a", cx_a).await;
-    active_call_a
-        .update(cx_a, |call, cx| call.set_location(Some(&project_a), cx))
-        .await
-        .unwrap();
-
-    let project_id = active_call_a
-        .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
-        .await
-        .unwrap();
-    let project_b = client_b.build_remote_project(project_id, cx_b).await;
-    active_call_b
-        .update(cx_b, |call, cx| call.set_location(Some(&project_b), cx))
-        .await
-        .unwrap();
-
-    // Client A opens some editors.
-    let workspace_a = client_a.build_workspace(&project_a, cx_a).root(cx_a);
-    let _editor_a1 = workspace_a
-        .update(cx_a, |workspace, cx| {
-            workspace.open_path((worktree_id, "1.txt"), None, true, cx)
-        })
-        .await
-        .unwrap()
-        .downcast::<Editor>()
-        .unwrap();
-
-    // Client B starts following client A.
-    let workspace_b = client_b.build_workspace(&project_b, cx_b).root(cx_b);
-    let pane_b = workspace_b.read_with(cx_b, |workspace, _| workspace.active_pane().clone());
-    let leader_id = project_b.read_with(cx_b, |project, _| {
-        project.collaborators().values().next().unwrap().peer_id
-    });
-    workspace_b
-        .update(cx_b, |workspace, cx| {
-            workspace.toggle_follow(leader_id, cx).unwrap()
-        })
-        .await
-        .unwrap();
-    assert_eq!(
-        workspace_b.read_with(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)),
-        Some(leader_id)
-    );
-    let editor_b2 = workspace_b.read_with(cx_b, |workspace, cx| {
-        workspace
-            .active_item(cx)
-            .unwrap()
-            .downcast::<Editor>()
-            .unwrap()
-    });
-
-    // When client B moves, it automatically stops following client A.
-    editor_b2.update(cx_b, |editor, cx| editor.move_right(&editor::MoveRight, cx));
-    assert_eq!(
-        workspace_b.read_with(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)),
-        None
-    );
-
-    workspace_b
-        .update(cx_b, |workspace, cx| {
-            workspace.toggle_follow(leader_id, cx).unwrap()
-        })
-        .await
-        .unwrap();
-    assert_eq!(
-        workspace_b.read_with(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)),
-        Some(leader_id)
-    );
-
-    // When client B edits, it automatically stops following client A.
-    editor_b2.update(cx_b, |editor, cx| editor.insert("X", cx));
-    assert_eq!(
-        workspace_b.read_with(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)),
-        None
-    );
-
-    workspace_b
-        .update(cx_b, |workspace, cx| {
-            workspace.toggle_follow(leader_id, cx).unwrap()
-        })
-        .await
-        .unwrap();
-    assert_eq!(
-        workspace_b.read_with(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)),
-        Some(leader_id)
-    );
-
-    // When client B scrolls, it automatically stops following client A.
-    editor_b2.update(cx_b, |editor, cx| {
-        editor.set_scroll_position(vec2f(0., 3.), cx)
-    });
-    assert_eq!(
-        workspace_b.read_with(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)),
-        None
-    );
-
-    workspace_b
-        .update(cx_b, |workspace, cx| {
-            workspace.toggle_follow(leader_id, cx).unwrap()
-        })
-        .await
-        .unwrap();
-    assert_eq!(
-        workspace_b.read_with(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)),
-        Some(leader_id)
-    );
-
-    // When client B activates a different pane, it continues following client A in the original pane.
-    workspace_b.update(cx_b, |workspace, cx| {
-        workspace.split_and_clone(pane_b.clone(), SplitDirection::Right, cx)
-    });
-    assert_eq!(
-        workspace_b.read_with(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)),
-        Some(leader_id)
-    );
-
-    workspace_b.update(cx_b, |workspace, cx| workspace.activate_next_pane(cx));
-    assert_eq!(
-        workspace_b.read_with(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)),
-        Some(leader_id)
-    );
-
-    // When client B activates a different item in the original pane, it automatically stops following client A.
-    workspace_b
-        .update(cx_b, |workspace, cx| {
-            workspace.open_path((worktree_id, "2.txt"), None, true, cx)
-        })
-        .await
-        .unwrap();
-    assert_eq!(
-        workspace_b.read_with(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)),
-        None
-    );
-}
-
-#[gpui::test(iterations = 10)]
-async fn test_peers_simultaneously_following_each_other(
-    deterministic: Arc<Deterministic>,
-    cx_a: &mut TestAppContext,
-    cx_b: &mut TestAppContext,
-) {
-    deterministic.forbid_parking();
-
-    let mut server = TestServer::start(&deterministic).await;
-    let client_a = server.create_client(cx_a, "user_a").await;
-    let client_b = server.create_client(cx_b, "user_b").await;
-    server
-        .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
-        .await;
-    let active_call_a = cx_a.read(ActiveCall::global);
-
-    cx_a.update(editor::init);
-    cx_b.update(editor::init);
-
-    client_a.fs().insert_tree("/a", json!({})).await;
-    let (project_a, _) = client_a.build_local_project("/a", cx_a).await;
-    let workspace_a = client_a.build_workspace(&project_a, cx_a).root(cx_a);
-    let project_id = active_call_a
-        .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
-        .await
-        .unwrap();
-
-    let project_b = client_b.build_remote_project(project_id, cx_b).await;
-    let workspace_b = client_b.build_workspace(&project_b, cx_b).root(cx_b);
-
-    deterministic.run_until_parked();
-    let client_a_id = project_b.read_with(cx_b, |project, _| {
-        project.collaborators().values().next().unwrap().peer_id
-    });
-    let client_b_id = project_a.read_with(cx_a, |project, _| {
-        project.collaborators().values().next().unwrap().peer_id
-    });
-
-    let a_follow_b = workspace_a.update(cx_a, |workspace, cx| {
-        workspace.toggle_follow(client_b_id, cx).unwrap()
-    });
-    let b_follow_a = workspace_b.update(cx_b, |workspace, cx| {
-        workspace.toggle_follow(client_a_id, cx).unwrap()
-    });
-
-    futures::try_join!(a_follow_b, b_follow_a).unwrap();
-    workspace_a.read_with(cx_a, |workspace, _| {
-        assert_eq!(
-            workspace.leader_for_pane(workspace.active_pane()),
-            Some(client_b_id)
-        );
-    });
-    workspace_b.read_with(cx_b, |workspace, _| {
-        assert_eq!(
-            workspace.leader_for_pane(workspace.active_pane()),
-            Some(client_a_id)
-        );
-    });
-}
-
 #[gpui::test(iterations = 10)]
 async fn test_on_input_format_from_host_to_guest(
     deterministic: Arc<Deterministic>,

crates/collab_ui/src/collab_panel.rs 🔗

@@ -47,7 +47,7 @@ use util::{iife, ResultExt, TryFutureExt};
 use workspace::{
     dock::{DockPosition, Panel},
     item::ItemHandle,
-    Workspace,
+    FollowNextCollaborator, Workspace,
 };
 
 #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
@@ -404,6 +404,7 @@ enum ListEntry {
     Header(Section),
     CallParticipant {
         user: Arc<User>,
+        peer_id: Option<PeerId>,
         is_pending: bool,
     },
     ParticipantProject {
@@ -508,14 +509,19 @@ impl CollabPanel {
                             let is_collapsed = this.collapsed_sections.contains(section);
                             this.render_header(*section, &theme, is_selected, is_collapsed, cx)
                         }
-                        ListEntry::CallParticipant { user, is_pending } => {
-                            Self::render_call_participant(
-                                user,
-                                *is_pending,
-                                is_selected,
-                                &theme.collab_panel,
-                            )
-                        }
+                        ListEntry::CallParticipant {
+                            user,
+                            peer_id,
+                            is_pending,
+                        } => Self::render_call_participant(
+                            user,
+                            *peer_id,
+                            this.user_store.clone(),
+                            *is_pending,
+                            is_selected,
+                            &theme,
+                            cx,
+                        ),
                         ListEntry::ParticipantProject {
                             project_id,
                             worktree_root_names,
@@ -528,7 +534,7 @@ impl CollabPanel {
                             Some(*project_id) == current_project_id,
                             *is_last,
                             is_selected,
-                            &theme.collab_panel,
+                            &theme,
                             cx,
                         ),
                         ListEntry::ParticipantScreen { peer_id, is_last } => {
@@ -793,6 +799,7 @@ impl CollabPanel {
                         let user_id = user.id;
                         self.entries.push(ListEntry::CallParticipant {
                             user,
+                            peer_id: None,
                             is_pending: false,
                         });
                         let mut projects = room.local_participant().projects.iter().peekable();
@@ -830,6 +837,7 @@ impl CollabPanel {
                     let participant = &room.remote_participants()[&user_id];
                     self.entries.push(ListEntry::CallParticipant {
                         user: participant.user.clone(),
+                        peer_id: Some(participant.peer_id),
                         is_pending: false,
                     });
                     let mut projects = participant.projects.iter().peekable();
@@ -871,6 +879,7 @@ impl CollabPanel {
                 self.entries
                     .extend(matches.iter().map(|mat| ListEntry::CallParticipant {
                         user: room.pending_participants()[mat.candidate_id].clone(),
+                        peer_id: None,
                         is_pending: true,
                     }));
             }
@@ -1174,46 +1183,97 @@ impl CollabPanel {
 
     fn render_call_participant(
         user: &User,
+        peer_id: Option<PeerId>,
+        user_store: ModelHandle<UserStore>,
         is_pending: bool,
         is_selected: bool,
-        theme: &theme::CollabPanel,
+        theme: &theme::Theme,
+        cx: &mut ViewContext<Self>,
     ) -> AnyElement<Self> {
-        Flex::row()
-            .with_children(user.avatar.clone().map(|avatar| {
-                Image::from_data(avatar)
-                    .with_style(theme.contact_avatar)
-                    .aligned()
-                    .left()
-            }))
-            .with_child(
-                Label::new(
-                    user.github_login.clone(),
-                    theme.contact_username.text.clone(),
-                )
-                .contained()
-                .with_style(theme.contact_username.container)
-                .aligned()
-                .left()
-                .flex(1., true),
-            )
-            .with_children(if is_pending {
-                Some(
-                    Label::new("Calling", theme.calling_indicator.text.clone())
+        enum CallParticipant {}
+        enum CallParticipantTooltip {}
+
+        let collab_theme = &theme.collab_panel;
+
+        let is_current_user =
+            user_store.read(cx).current_user().map(|user| user.id) == Some(user.id);
+
+        let content =
+            MouseEventHandler::new::<CallParticipant, _>(user.id as usize, cx, |mouse_state, _| {
+                let style = if is_current_user {
+                    *collab_theme
+                        .contact_row
+                        .in_state(is_selected)
+                        .style_for(&mut Default::default())
+                } else {
+                    *collab_theme
+                        .contact_row
+                        .in_state(is_selected)
+                        .style_for(mouse_state)
+                };
+
+                Flex::row()
+                    .with_children(user.avatar.clone().map(|avatar| {
+                        Image::from_data(avatar)
+                            .with_style(collab_theme.contact_avatar)
+                            .aligned()
+                            .left()
+                    }))
+                    .with_child(
+                        Label::new(
+                            user.github_login.clone(),
+                            collab_theme.contact_username.text.clone(),
+                        )
                         .contained()
-                        .with_style(theme.calling_indicator.container)
-                        .aligned(),
-                )
-            } else {
-                None
+                        .with_style(collab_theme.contact_username.container)
+                        .aligned()
+                        .left()
+                        .flex(1., true),
+                    )
+                    .with_children(if is_pending {
+                        Some(
+                            Label::new("Calling", collab_theme.calling_indicator.text.clone())
+                                .contained()
+                                .with_style(collab_theme.calling_indicator.container)
+                                .aligned(),
+                        )
+                    } else if is_current_user {
+                        Some(
+                            Label::new("You", collab_theme.calling_indicator.text.clone())
+                                .contained()
+                                .with_style(collab_theme.calling_indicator.container)
+                                .aligned(),
+                        )
+                    } else {
+                        None
+                    })
+                    .constrained()
+                    .with_height(collab_theme.row_height)
+                    .contained()
+                    .with_style(style)
+            });
+
+        if is_current_user || is_pending || peer_id.is_none() {
+            return content.into_any();
+        }
+
+        let tooltip = format!("Follow {}", user.github_login);
+
+        content
+            .on_click(MouseButton::Left, move |_, this, cx| {
+                if let Some(workspace) = this.workspace.upgrade(cx) {
+                    workspace
+                        .update(cx, |workspace, cx| workspace.follow(peer_id.unwrap(), cx))
+                        .map(|task| task.detach_and_log_err(cx));
+                }
             })
-            .constrained()
-            .with_height(theme.row_height)
-            .contained()
-            .with_style(
-                *theme
-                    .contact_row
-                    .in_state(is_selected)
-                    .style_for(&mut Default::default()),
+            .with_cursor_style(CursorStyle::PointingHand)
+            .with_tooltip::<CallParticipantTooltip>(
+                user.id as usize,
+                tooltip,
+                Some(Box::new(FollowNextCollaborator)),
+                theme.tooltip.clone(),
+                cx,
             )
             .into_any()
     }
@@ -1225,74 +1285,91 @@ impl CollabPanel {
         is_current: bool,
         is_last: bool,
         is_selected: bool,
-        theme: &theme::CollabPanel,
+        theme: &theme::Theme,
         cx: &mut ViewContext<Self>,
     ) -> AnyElement<Self> {
         enum JoinProject {}
+        enum JoinProjectTooltip {}
 
-        let host_avatar_width = theme
+        let collab_theme = &theme.collab_panel;
+        let host_avatar_width = collab_theme
             .contact_avatar
             .width
-            .or(theme.contact_avatar.height)
+            .or(collab_theme.contact_avatar.height)
             .unwrap_or(0.);
-        let tree_branch = theme.tree_branch;
+        let tree_branch = collab_theme.tree_branch;
         let project_name = if worktree_root_names.is_empty() {
             "untitled".to_string()
         } else {
             worktree_root_names.join(", ")
         };
 
-        MouseEventHandler::new::<JoinProject, _>(project_id as usize, cx, |mouse_state, cx| {
-            let tree_branch = *tree_branch.in_state(is_selected).style_for(mouse_state);
-            let row = theme
-                .project_row
-                .in_state(is_selected)
-                .style_for(mouse_state);
+        let content =
+            MouseEventHandler::new::<JoinProject, _>(project_id as usize, cx, |mouse_state, cx| {
+                let tree_branch = *tree_branch.in_state(is_selected).style_for(mouse_state);
+                let row = if is_current {
+                    collab_theme
+                        .project_row
+                        .in_state(true)
+                        .style_for(&mut Default::default())
+                } else {
+                    collab_theme
+                        .project_row
+                        .in_state(is_selected)
+                        .style_for(mouse_state)
+                };
 
-            Flex::row()
-                .with_child(render_tree_branch(
-                    tree_branch,
-                    &row.name.text,
-                    is_last,
-                    vec2f(host_avatar_width, theme.row_height),
-                    cx.font_cache(),
-                ))
-                .with_child(
-                    Svg::new("icons/file_icons/folder.svg")
-                        .with_color(theme.channel_hash.color)
-                        .constrained()
-                        .with_width(theme.channel_hash.width)
-                        .aligned()
-                        .left(),
-                )
-                .with_child(
-                    Label::new(project_name, row.name.text.clone())
-                        .aligned()
-                        .left()
-                        .contained()
-                        .with_style(row.name.container)
-                        .flex(1., false),
-                )
-                .constrained()
-                .with_height(theme.row_height)
-                .contained()
-                .with_style(row.container)
-        })
-        .with_cursor_style(if !is_current {
-            CursorStyle::PointingHand
-        } else {
-            CursorStyle::Arrow
-        })
-        .on_click(MouseButton::Left, move |_, this, cx| {
-            if !is_current {
+                Flex::row()
+                    .with_child(render_tree_branch(
+                        tree_branch,
+                        &row.name.text,
+                        is_last,
+                        vec2f(host_avatar_width, collab_theme.row_height),
+                        cx.font_cache(),
+                    ))
+                    .with_child(
+                        Svg::new("icons/file_icons/folder.svg")
+                            .with_color(collab_theme.channel_hash.color)
+                            .constrained()
+                            .with_width(collab_theme.channel_hash.width)
+                            .aligned()
+                            .left(),
+                    )
+                    .with_child(
+                        Label::new(project_name.clone(), row.name.text.clone())
+                            .aligned()
+                            .left()
+                            .contained()
+                            .with_style(row.name.container)
+                            .flex(1., false),
+                    )
+                    .constrained()
+                    .with_height(collab_theme.row_height)
+                    .contained()
+                    .with_style(row.container)
+            });
+
+        if is_current {
+            return content.into_any();
+        }
+
+        content
+            .with_cursor_style(CursorStyle::PointingHand)
+            .on_click(MouseButton::Left, move |_, this, cx| {
                 if let Some(workspace) = this.workspace.upgrade(cx) {
                     let app_state = workspace.read(cx).app_state().clone();
                     workspace::join_remote_project(project_id, host_user_id, app_state, cx)
                         .detach_and_log_err(cx);
                 }
-            }
-        })
-        .into_any()
+            })
+            .with_tooltip::<JoinProjectTooltip>(
+                project_id as usize,
+                format!("Open {}", project_name),
+                None,
+                theme.tooltip.clone(),
+                cx,
+            )
+            .into_any()
     }
 
     fn render_participant_screen(

crates/collab_ui/src/collab_titlebar_item.rs 🔗

@@ -215,7 +215,13 @@ impl CollabTitlebarItem {
         let git_style = theme.titlebar.git_menu_button.clone();
         let item_spacing = theme.titlebar.item_spacing;
 
-        let mut ret = Flex::row().with_child(
+        let mut ret = Flex::row();
+
+        if let Some(project_host) = self.collect_project_host(theme.clone(), cx) {
+            ret = ret.with_child(project_host)
+        }
+
+        ret = ret.with_child(
             Stack::new()
                 .with_child(
                     MouseEventHandler::new::<ToggleProjectMenu, _>(0, cx, |mouse_state, cx| {
@@ -283,6 +289,71 @@ impl CollabTitlebarItem {
         ret.into_any()
     }
 
+    fn collect_project_host(
+        &self,
+        theme: Arc<Theme>,
+        cx: &mut ViewContext<Self>,
+    ) -> Option<AnyElement<Self>> {
+        if ActiveCall::global(cx).read(cx).room().is_none() {
+            return None;
+        }
+        let project = self.project.read(cx);
+        let user_store = self.user_store.read(cx);
+
+        if project.is_local() {
+            return None;
+        }
+
+        let Some(host) = project.host() else {
+            return None;
+        };
+        let (Some(host_user), Some(participant_index)) = (
+            user_store.get_cached_user(host.user_id),
+            user_store.participant_indices().get(&host.user_id),
+        ) else {
+            return None;
+        };
+
+        enum ProjectHost {}
+        enum ProjectHostTooltip {}
+
+        let host_style = theme.titlebar.project_host.clone();
+        let selection_style = theme
+            .editor
+            .selection_style_for_room_participant(participant_index.0);
+        let peer_id = host.peer_id.clone();
+
+        Some(
+            MouseEventHandler::new::<ProjectHost, _>(0, cx, |mouse_state, _| {
+                let mut host_style = host_style.style_for(mouse_state).clone();
+                host_style.text.color = selection_style.cursor;
+                Label::new(host_user.github_login.clone(), host_style.text)
+                    .contained()
+                    .with_style(host_style.container)
+                    .aligned()
+                    .left()
+            })
+            .with_cursor_style(CursorStyle::PointingHand)
+            .on_click(MouseButton::Left, move |_, this, cx| {
+                if let Some(workspace) = this.workspace.upgrade(cx) {
+                    if let Some(task) =
+                        workspace.update(cx, |workspace, cx| workspace.follow(peer_id, cx))
+                    {
+                        task.detach_and_log_err(cx);
+                    }
+                }
+            })
+            .with_tooltip::<ProjectHostTooltip>(
+                0,
+                host_user.github_login.clone() + " is sharing this project. Click to follow.",
+                None,
+                theme.tooltip.clone(),
+                cx,
+            )
+            .into_any_named("project-host"),
+        )
+    }
+
     fn window_activation_changed(&mut self, active: bool, cx: &mut ViewContext<Self>) {
         let project = if active {
             Some(self.project.clone())
@@ -877,7 +948,7 @@ impl CollabTitlebarItem {
     fn render_face_pile(
         &self,
         user: &User,
-        replica_id: Option<ReplicaId>,
+        _replica_id: Option<ReplicaId>,
         peer_id: PeerId,
         location: Option<ParticipantLocation>,
         muted: bool,
@@ -1019,55 +1090,30 @@ impl CollabTitlebarItem {
             },
         );
 
-        match (replica_id, location) {
-            // If the user's location isn't known, do nothing.
-            (_, None) => content.into_any(),
-
-            // If the user is not in this project, but is in another share project,
-            // join that project.
-            (None, Some(ParticipantLocation::SharedProject { project_id })) => content
-                .with_cursor_style(CursorStyle::PointingHand)
-                .on_click(MouseButton::Left, move |_, this, cx| {
-                    if let Some(workspace) = this.workspace.upgrade(cx) {
-                        let app_state = workspace.read(cx).app_state().clone();
-                        workspace::join_remote_project(project_id, user_id, app_state, cx)
-                            .detach_and_log_err(cx);
-                    }
-                })
-                .with_tooltip::<TitlebarParticipant>(
-                    peer_id.as_u64() as usize,
-                    format!("Follow {} into external project", user.github_login),
-                    Some(Box::new(FollowNextCollaborator)),
-                    theme.tooltip.clone(),
-                    cx,
-                )
-                .into_any(),
-
-            // Otherwise, follow the user in the current window.
-            _ => content
-                .with_cursor_style(CursorStyle::PointingHand)
-                .on_click(MouseButton::Left, move |_, item, cx| {
-                    if let Some(workspace) = item.workspace.upgrade(cx) {
-                        if let Some(task) = workspace
-                            .update(cx, |workspace, cx| workspace.toggle_follow(peer_id, cx))
-                        {
-                            task.detach_and_log_err(cx);
-                        }
-                    }
-                })
-                .with_tooltip::<TitlebarParticipant>(
-                    peer_id.as_u64() as usize,
-                    if self_following {
-                        format!("Unfollow {}", user.github_login)
-                    } else {
-                        format!("Follow {}", user.github_login)
-                    },
-                    Some(Box::new(FollowNextCollaborator)),
-                    theme.tooltip.clone(),
-                    cx,
-                )
-                .into_any(),
+        if Some(peer_id) == self_peer_id {
+            return content.into_any();
         }
+
+        content
+            .with_cursor_style(CursorStyle::PointingHand)
+            .on_click(MouseButton::Left, move |_, this, cx| {
+                let Some(workspace) = this.workspace.upgrade(cx) else {
+                    return;
+                };
+                if let Some(task) =
+                    workspace.update(cx, |workspace, cx| workspace.follow(peer_id, cx))
+                {
+                    task.detach_and_log_err(cx);
+                }
+            })
+            .with_tooltip::<TitlebarParticipant>(
+                peer_id.as_u64() as usize,
+                format!("Follow {}", user.github_login),
+                Some(Box::new(FollowNextCollaborator)),
+                theme.tooltip.clone(),
+                cx,
+            )
+            .into_any()
     }
 
     fn location_style(

crates/collab_ui/src/collab_ui.rs 🔗

@@ -7,7 +7,7 @@ mod face_pile;
 mod incoming_call_notification;
 mod notifications;
 mod panel_settings;
-mod project_shared_notification;
+pub mod project_shared_notification;
 mod sharing_status_indicator;
 
 use call::{report_call_event_for_room, ActiveCall, Room};

crates/collab_ui/src/project_shared_notification.rs 🔗

@@ -40,7 +40,9 @@ pub fn init(app_state: &Arc<AppState>, cx: &mut AppContext) {
                     .push(window);
             }
         }
-        room::Event::RemoteProjectUnshared { project_id } => {
+        room::Event::RemoteProjectUnshared { project_id }
+        | room::Event::RemoteProjectJoined { project_id }
+        | room::Event::RemoteProjectInvitationDiscarded { project_id } => {
             if let Some(windows) = notification_windows.remove(&project_id) {
                 for window in windows {
                     window.remove(cx);
@@ -82,7 +84,6 @@ impl ProjectSharedNotification {
     }
 
     fn join(&mut self, cx: &mut ViewContext<Self>) {
-        cx.remove_window();
         if let Some(app_state) = self.app_state.upgrade() {
             workspace::join_remote_project(self.project_id, self.owner.id, app_state, cx)
                 .detach_and_log_err(cx);
@@ -90,7 +91,15 @@ impl ProjectSharedNotification {
     }
 
     fn dismiss(&mut self, cx: &mut ViewContext<Self>) {
-        cx.remove_window();
+        if let Some(active_room) =
+            ActiveCall::global(cx).read_with(cx, |call, _| call.room().cloned())
+        {
+            active_room.update(cx, |_, cx| {
+                cx.emit(room::Event::RemoteProjectInvitationDiscarded {
+                    project_id: self.project_id,
+                });
+            });
+        }
     }
 
     fn render_owner(&self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {

crates/gpui/src/platform/test.rs 🔗

@@ -103,6 +103,7 @@ pub struct Platform {
     current_clipboard_item: Mutex<Option<ClipboardItem>>,
     cursor: Mutex<CursorStyle>,
     active_window: Arc<Mutex<Option<AnyWindowHandle>>>,
+    active_screen: Screen,
 }
 
 impl Platform {
@@ -113,6 +114,7 @@ impl Platform {
             current_clipboard_item: Default::default(),
             cursor: Mutex::new(CursorStyle::Arrow),
             active_window: Default::default(),
+            active_screen: Screen::new(),
         }
     }
 }
@@ -136,12 +138,16 @@ impl super::Platform for Platform {
 
     fn quit(&self) {}
 
-    fn screen_by_id(&self, _id: uuid::Uuid) -> Option<Rc<dyn crate::platform::Screen>> {
-        None
+    fn screen_by_id(&self, uuid: uuid::Uuid) -> Option<Rc<dyn crate::platform::Screen>> {
+        if self.active_screen.uuid == uuid {
+            Some(Rc::new(self.active_screen.clone()))
+        } else {
+            None
+        }
     }
 
     fn screens(&self) -> Vec<Rc<dyn crate::platform::Screen>> {
-        Default::default()
+        vec![Rc::new(self.active_screen.clone())]
     }
 
     fn open_window(
@@ -158,6 +164,7 @@ impl super::Platform for Platform {
                 WindowBounds::Fixed(rect) => rect.size(),
             },
             self.active_window.clone(),
+            Rc::new(self.active_screen.clone()),
         ))
     }
 
@@ -170,6 +177,7 @@ impl super::Platform for Platform {
             handle,
             vec2f(24., 24.),
             self.active_window.clone(),
+            Rc::new(self.active_screen.clone()),
         ))
     }
 
@@ -238,8 +246,18 @@ impl super::Platform for Platform {
     fn restart(&self) {}
 }
 
-#[derive(Debug)]
-pub struct Screen;
+#[derive(Debug, Clone)]
+pub struct Screen {
+    uuid: uuid::Uuid,
+}
+
+impl Screen {
+    fn new() -> Self {
+        Self {
+            uuid: uuid::Uuid::new_v4(),
+        }
+    }
+}
 
 impl super::Screen for Screen {
     fn as_any(&self) -> &dyn Any {
@@ -255,7 +273,7 @@ impl super::Screen for Screen {
     }
 
     fn display_uuid(&self) -> Option<uuid::Uuid> {
-        Some(uuid::Uuid::new_v4())
+        Some(self.uuid)
     }
 }
 
@@ -275,6 +293,7 @@ pub struct Window {
     pub(crate) edited: bool,
     pub(crate) pending_prompts: RefCell<VecDeque<oneshot::Sender<usize>>>,
     active_window: Arc<Mutex<Option<AnyWindowHandle>>>,
+    screen: Rc<Screen>,
 }
 
 impl Window {
@@ -282,6 +301,7 @@ impl Window {
         handle: AnyWindowHandle,
         size: Vector2F,
         active_window: Arc<Mutex<Option<AnyWindowHandle>>>,
+        screen: Rc<Screen>,
     ) -> Self {
         Self {
             handle,
@@ -299,6 +319,7 @@ impl Window {
             edited: false,
             pending_prompts: Default::default(),
             active_window,
+            screen,
         }
     }
 
@@ -329,7 +350,7 @@ impl super::Window for Window {
     }
 
     fn screen(&self) -> Rc<dyn crate::platform::Screen> {
-        Rc::new(Screen)
+        self.screen.clone()
     }
 
     fn mouse_position(&self) -> Vector2F {

crates/project/src/project.rs 🔗

@@ -975,6 +975,10 @@ impl Project {
         &self.collaborators
     }
 
+    pub fn host(&self) -> Option<&Collaborator> {
+        self.collaborators.values().find(|c| c.replica_id == 0)
+    }
+
     /// Collect all worktrees, including ones that don't appear in the project panel
     pub fn worktrees<'a>(
         &'a self,

crates/theme/src/theme.rs 🔗

@@ -131,6 +131,7 @@ pub struct Titlebar {
     pub menu: TitlebarMenu,
     pub project_menu_button: Toggleable<Interactive<ContainedText>>,
     pub git_menu_button: Toggleable<Interactive<ContainedText>>,
+    pub project_host: Interactive<ContainedText>,
     pub item_spacing: f32,
     pub face_pile_spacing: f32,
     pub avatar_ribbon: AvatarRibbon,

crates/workspace/src/pane_group.rs 🔗

@@ -222,7 +222,7 @@ impl Member {
                                         |_, _| {
                                             Label::new(
                                                 format!(
-                                                    "Follow {} on their active project",
+                                                    "Follow {} to their active project",
                                                     leader_user.github_login,
                                                 ),
                                                 theme

crates/workspace/src/workspace.rs 🔗

@@ -2520,19 +2520,13 @@ impl Workspace {
         cx.notify();
     }
 
-    pub fn toggle_follow(
+    fn start_following(
         &mut self,
         leader_id: PeerId,
         cx: &mut ViewContext<Self>,
     ) -> Option<Task<Result<()>>> {
         let pane = self.active_pane().clone();
 
-        if let Some(prev_leader_id) = self.unfollow(&pane, cx) {
-            if leader_id == prev_leader_id {
-                return None;
-            }
-        }
-
         self.last_leaders_by_pane
             .insert(pane.downgrade(), leader_id);
         self.follower_states_by_leader
@@ -2603,9 +2597,64 @@ impl Workspace {
             None
         };
 
-        next_leader_id
-            .or_else(|| collaborators.keys().copied().next())
-            .and_then(|leader_id| self.toggle_follow(leader_id, cx))
+        let pane = self.active_pane.clone();
+        let Some(leader_id) = next_leader_id.or_else(|| collaborators.keys().copied().next())
+        else {
+            return None;
+        };
+        if Some(leader_id) == self.unfollow(&pane, cx) {
+            return None;
+        }
+        self.follow(leader_id, cx)
+    }
+
+    pub fn follow(
+        &mut self,
+        leader_id: PeerId,
+        cx: &mut ViewContext<Self>,
+    ) -> Option<Task<Result<()>>> {
+        let room = ActiveCall::global(cx).read(cx).room()?.read(cx);
+        let project = self.project.read(cx);
+
+        let Some(remote_participant) = room.remote_participant_for_peer_id(leader_id) else {
+            return None;
+        };
+
+        let other_project_id = match remote_participant.location {
+            call::ParticipantLocation::External => None,
+            call::ParticipantLocation::UnsharedProject => None,
+            call::ParticipantLocation::SharedProject { project_id } => {
+                if Some(project_id) == project.remote_id() {
+                    None
+                } else {
+                    Some(project_id)
+                }
+            }
+        };
+
+        // if they are active in another project, follow there.
+        if let Some(project_id) = other_project_id {
+            let app_state = self.app_state.clone();
+            return Some(crate::join_remote_project(
+                project_id,
+                remote_participant.user.id,
+                app_state,
+                cx,
+            ));
+        }
+
+        // if you're already following, find the right pane and focus it.
+        for (existing_leader_id, states_by_pane) in &mut self.follower_states_by_leader {
+            if leader_id == *existing_leader_id {
+                for (pane, _) in states_by_pane {
+                    cx.focus(pane);
+                    return None;
+                }
+            }
+        }
+
+        // Otherwise, follow.
+        self.start_following(leader_id, cx)
     }
 
     pub fn unfollow(
@@ -4197,21 +4246,20 @@ pub fn join_remote_project(
     cx: &mut AppContext,
 ) -> Task<Result<()>> {
     cx.spawn(|mut cx| async move {
-        let existing_workspace = cx
-            .windows()
-            .into_iter()
-            .find_map(|window| {
-                window.downcast::<Workspace>().and_then(|window| {
-                    window.read_root_with(&cx, |workspace, cx| {
+        let windows = cx.windows();
+        let existing_workspace = windows.into_iter().find_map(|window| {
+            window.downcast::<Workspace>().and_then(|window| {
+                window
+                    .read_root_with(&cx, |workspace, cx| {
                         if workspace.project().read(cx).remote_id() == Some(project_id) {
                             Some(cx.handle().downgrade())
                         } else {
                             None
                         }
                     })
-                })
+                    .unwrap_or(None)
             })
-            .flatten();
+        });
 
         let workspace = if let Some(existing_workspace) = existing_workspace {
             existing_workspace
@@ -4276,11 +4324,9 @@ pub fn join_remote_project(
                     });
 
                 if let Some(follow_peer_id) = follow_peer_id {
-                    if !workspace.is_being_followed(follow_peer_id) {
-                        workspace
-                            .toggle_follow(follow_peer_id, cx)
-                            .map(|follow| follow.detach_and_log_err(cx));
-                    }
+                    workspace
+                        .follow(follow_peer_id, cx)
+                        .map(|follow| follow.detach_and_log_err(cx));
                 }
             }
         })?;

styles/src/style_tree/titlebar.ts 🔗

@@ -1,4 +1,4 @@
-import { icon_button, toggleable_icon_button, toggleable_text_button } from "../component"
+import { icon_button, text_button, toggleable_icon_button, toggleable_text_button } from "../component"
 import { interactive, toggleable } from "../element"
 import { useTheme, with_opacity } from "../theme"
 import { background, border, foreground, text } from "./components"
@@ -191,6 +191,12 @@ export function titlebar(): any {
             color: "variant",
         }),
 
+        project_host: text_button({
+            text_properties: {
+                weight: "bold"
+            }
+        }),
+
         // Collaborators
         leader_avatar: {
             width: avatar_width,