Show private projects in the contacts panel

Max Brunsfeld created

Introduce a ProjectStore that lets you iterate through all open projects.
Allow projects to be made public by clicking the lock.

Change summary

assets/icons/lock-8.svg                     |   3 
crates/client/src/client.rs                 |  59 +-
crates/client/src/test.rs                   |   5 
crates/collab/src/integration_tests.rs      | 122 ++---
crates/contacts_panel/src/contacts_panel.rs | 487 +++++++++++++++-------
crates/gpui/src/app.rs                      |   4 
crates/gpui/src/platform.rs                 |   6 
crates/project/src/project.rs               | 106 ++++
crates/theme/src/theme.rs                   |   1 
crates/workspace/src/waiting_room.rs        |   3 
crates/workspace/src/workspace.rs           | 123 +++--
crates/zed/src/main.rs                      |   4 
crates/zed/src/zed.rs                       |   8 
styles/src/styleTree/contactsPanel.ts       |   5 
14 files changed, 627 insertions(+), 309 deletions(-)

Detailed changes

assets/icons/lock-8.svg 🔗

@@ -0,0 +1,3 @@
+<svg width="8" height="8" viewBox="0 0 8 8" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M1.75 3V2.25C1.75 1.00734 2.75781 0 4 0C5.24219 0 6.25 1.00734 6.25 2.25V3H6.5C7.05156 3 7.5 3.44844 7.5 4V7C7.5 7.55156 7.05156 8 6.5 8H1.5C0.947656 8 0.5 7.55156 0.5 7V4C0.5 3.44844 0.947656 3 1.5 3H1.75ZM2.75 3H5.25V2.25C5.25 1.55969 4.69063 1 4 1C3.30938 1 2.75 1.55969 2.75 2.25V3Z" fill="#8B8792"/>
+</svg>

crates/client/src/client.rs 🔗

@@ -67,17 +67,23 @@ pub struct Client {
     peer: Arc<Peer>,
     http: Arc<dyn HttpClient>,
     state: RwLock<ClientState>,
-    authenticate:
+
+    #[cfg(any(test, feature = "test-support"))]
+    authenticate: RwLock<
         Option<Box<dyn 'static + Send + Sync + Fn(&AsyncAppContext) -> Task<Result<Credentials>>>>,
-    establish_connection: Option<
-        Box<
-            dyn 'static
-                + Send
-                + Sync
-                + Fn(
-                    &Credentials,
-                    &AsyncAppContext,
-                ) -> Task<Result<Connection, EstablishConnectionError>>,
+    >,
+    #[cfg(any(test, feature = "test-support"))]
+    establish_connection: RwLock<
+        Option<
+            Box<
+                dyn 'static
+                    + Send
+                    + Sync
+                    + Fn(
+                        &Credentials,
+                        &AsyncAppContext,
+                    ) -> Task<Result<Connection, EstablishConnectionError>>,
+            >,
         >,
     >,
 }
@@ -235,8 +241,11 @@ impl Client {
             peer: Peer::new(),
             http,
             state: Default::default(),
-            authenticate: None,
-            establish_connection: None,
+
+            #[cfg(any(test, feature = "test-support"))]
+            authenticate: Default::default(),
+            #[cfg(any(test, feature = "test-support"))]
+            establish_connection: Default::default(),
         })
     }
 
@@ -260,23 +269,23 @@ impl Client {
     }
 
     #[cfg(any(test, feature = "test-support"))]
-    pub fn override_authenticate<F>(&mut self, authenticate: F) -> &mut Self
+    pub fn override_authenticate<F>(&self, authenticate: F) -> &Self
     where
         F: 'static + Send + Sync + Fn(&AsyncAppContext) -> Task<Result<Credentials>>,
     {
-        self.authenticate = Some(Box::new(authenticate));
+        *self.authenticate.write() = Some(Box::new(authenticate));
         self
     }
 
     #[cfg(any(test, feature = "test-support"))]
-    pub fn override_establish_connection<F>(&mut self, connect: F) -> &mut Self
+    pub fn override_establish_connection<F>(&self, connect: F) -> &Self
     where
         F: 'static
             + Send
             + Sync
             + Fn(&Credentials, &AsyncAppContext) -> Task<Result<Connection, EstablishConnectionError>>,
     {
-        self.establish_connection = Some(Box::new(connect));
+        *self.establish_connection.write() = Some(Box::new(connect));
         self
     }
 
@@ -755,11 +764,12 @@ impl Client {
     }
 
     fn authenticate(self: &Arc<Self>, cx: &AsyncAppContext) -> Task<Result<Credentials>> {
-        if let Some(callback) = self.authenticate.as_ref() {
-            callback(cx)
-        } else {
-            self.authenticate_with_browser(cx)
+        #[cfg(any(test, feature = "test-support"))]
+        if let Some(callback) = self.authenticate.read().as_ref() {
+            return callback(cx);
         }
+
+        self.authenticate_with_browser(cx)
     }
 
     fn establish_connection(
@@ -767,11 +777,12 @@ impl Client {
         credentials: &Credentials,
         cx: &AsyncAppContext,
     ) -> Task<Result<Connection, EstablishConnectionError>> {
-        if let Some(callback) = self.establish_connection.as_ref() {
-            callback(credentials, cx)
-        } else {
-            self.establish_websocket_connection(credentials, cx)
+        #[cfg(any(test, feature = "test-support"))]
+        if let Some(callback) = self.establish_connection.read().as_ref() {
+            return callback(credentials, cx);
         }
+
+        self.establish_websocket_connection(credentials, cx)
     }
 
     fn establish_websocket_connection(

crates/client/src/test.rs 🔗

@@ -28,7 +28,7 @@ struct FakeServerState {
 impl FakeServer {
     pub async fn for_client(
         client_user_id: u64,
-        client: &mut Arc<Client>,
+        client: &Arc<Client>,
         cx: &TestAppContext,
     ) -> Self {
         let server = Self {
@@ -38,8 +38,7 @@ impl FakeServer {
             executor: cx.foreground(),
         };
 
-        Arc::get_mut(client)
-            .unwrap()
+        client
             .override_authenticate({
                 let state = Arc::downgrade(&server.state);
                 move |cx| {

crates/collab/src/integration_tests.rs 🔗

@@ -30,7 +30,7 @@ use project::{
     fs::{FakeFs, Fs as _},
     search::SearchQuery,
     worktree::WorktreeHandle,
-    DiagnosticSummary, Project, ProjectPath, WorktreeId,
+    DiagnosticSummary, Project, ProjectPath, ProjectStore, WorktreeId,
 };
 use rand::prelude::*;
 use rpc::PeerId;
@@ -174,9 +174,10 @@ async fn test_share_project(
         project_id,
         client_b2.client.clone(),
         client_b2.user_store.clone(),
+        client_b2.project_store.clone(),
         client_b2.language_registry.clone(),
         FakeFs::new(cx_b2.background()),
-        &mut cx_b2.to_async(),
+        cx_b2.to_async(),
     )
     .await
     .unwrap();
@@ -310,16 +311,16 @@ async fn test_host_disconnect(
         .unwrap();
 
     // Request to join that project as client C
-    let project_c = cx_c.spawn(|mut cx| async move {
+    let project_c = cx_c.spawn(|cx| {
         Project::remote(
             project_id,
             client_c.client.clone(),
             client_c.user_store.clone(),
+            client_c.project_store.clone(),
             client_c.language_registry.clone(),
             FakeFs::new(cx.background()),
-            &mut cx,
+            cx,
         )
-        .await
     });
     deterministic.run_until_parked();
 
@@ -372,21 +373,16 @@ async fn test_decline_join_request(
     let project_id = project_a.read_with(cx_a, |project, _| project.remote_id().unwrap());
 
     // Request to join that project as client B
-    let project_b = cx_b.spawn(|mut cx| {
-        let client = client_b.client.clone();
-        let user_store = client_b.user_store.clone();
-        let language_registry = client_b.language_registry.clone();
-        async move {
-            Project::remote(
-                project_id,
-                client,
-                user_store,
-                language_registry,
-                FakeFs::new(cx.background()),
-                &mut cx,
-            )
-            .await
-        }
+    let project_b = cx_b.spawn(|cx| {
+        Project::remote(
+            project_id,
+            client_b.client.clone(),
+            client_b.user_store.clone(),
+            client_b.project_store.clone(),
+            client_b.language_registry.clone(),
+            FakeFs::new(cx.background()),
+            cx,
+        )
     });
     deterministic.run_until_parked();
     project_a.update(cx_a, |project, cx| {
@@ -398,20 +394,16 @@ async fn test_decline_join_request(
     ));
 
     // Request to join the project again as client B
-    let project_b = cx_b.spawn(|mut cx| {
-        let client = client_b.client.clone();
-        let user_store = client_b.user_store.clone();
-        async move {
-            Project::remote(
-                project_id,
-                client,
-                user_store,
-                client_b.language_registry.clone(),
-                FakeFs::new(cx.background()),
-                &mut cx,
-            )
-            .await
-        }
+    let project_b = cx_b.spawn(|cx| {
+        Project::remote(
+            project_id,
+            client_b.client.clone(),
+            client_b.user_store.clone(),
+            client_b.project_store.clone(),
+            client_b.language_registry.clone(),
+            FakeFs::new(cx.background()),
+            cx,
+        )
     });
 
     // Close the project on the host
@@ -467,21 +459,16 @@ async fn test_cancel_join_request(
     });
 
     // Request to join that project as client B
-    let project_b = cx_b.spawn(|mut cx| {
-        let client = client_b.client.clone();
-        let user_store = client_b.user_store.clone();
-        let language_registry = client_b.language_registry.clone();
-        async move {
-            Project::remote(
-                project_id,
-                client,
-                user_store,
-                language_registry.clone(),
-                FakeFs::new(cx.background()),
-                &mut cx,
-            )
-            .await
-        }
+    let project_b = cx_b.spawn(|cx| {
+        Project::remote(
+            project_id,
+            client_b.client.clone(),
+            client_b.user_store.clone(),
+            client_b.project_store.clone(),
+            client_b.language_registry.clone().clone(),
+            FakeFs::new(cx.background()),
+            cx,
+        )
     });
     deterministic.run_until_parked();
     assert_eq!(
@@ -529,6 +516,7 @@ async fn test_private_projects(
             false,
             client_a.client.clone(),
             client_a.user_store.clone(),
+            client_a.project_store.clone(),
             client_a.language_registry.clone(),
             fs.clone(),
             cx,
@@ -4076,6 +4064,7 @@ async fn test_random_collaboration(
             true,
             host.client.clone(),
             host.user_store.clone(),
+            host.project_store.clone(),
             host_language_registry.clone(),
             fs.clone(),
             cx,
@@ -4311,9 +4300,10 @@ async fn test_random_collaboration(
                     host_project_id,
                     guest.client.clone(),
                     guest.user_store.clone(),
+                    guest.project_store.clone(),
                     guest_lang_registry.clone(),
                     FakeFs::new(cx.background()),
-                    &mut guest_cx.to_async(),
+                    guest_cx.to_async(),
                 )
                 .await
                 .unwrap();
@@ -4614,9 +4604,11 @@ impl TestServer {
             });
 
         let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http, cx));
+        let project_store = cx.add_model(|_| ProjectStore::default());
         let app_state = Arc::new(workspace::AppState {
             client: client.clone(),
             user_store: user_store.clone(),
+            project_store: project_store.clone(),
             languages: Arc::new(LanguageRegistry::new(Task::ready(()))),
             themes: ThemeRegistry::new((), cx.font_cache()),
             fs: FakeFs::new(cx.background()),
@@ -4639,6 +4631,7 @@ impl TestServer {
             peer_id,
             username: name.to_string(),
             user_store,
+            project_store,
             language_registry: Arc::new(LanguageRegistry::test()),
             project: Default::default(),
             buffers: Default::default(),
@@ -4732,6 +4725,7 @@ struct TestClient {
     username: String,
     pub peer_id: PeerId,
     pub user_store: ModelHandle<UserStore>,
+    pub project_store: ModelHandle<ProjectStore>,
     language_registry: Arc<LanguageRegistry>,
     project: Option<ModelHandle<Project>>,
     buffers: HashSet<ModelHandle<language::Buffer>>,
@@ -4803,6 +4797,7 @@ impl TestClient {
                 true,
                 self.client.clone(),
                 self.user_store.clone(),
+                self.project_store.clone(),
                 self.language_registry.clone(),
                 fs,
                 cx,
@@ -4835,27 +4830,22 @@ impl TestClient {
             .await;
         let guest_user_id = self.user_id().unwrap();
         let languages = host_project.read_with(host_cx, |project, _| project.languages().clone());
-        let project_b = guest_cx.spawn(|mut cx| {
-            let user_store = self.user_store.clone();
-            let guest_client = self.client.clone();
-            async move {
-                Project::remote(
-                    host_project_id,
-                    guest_client,
-                    user_store.clone(),
-                    languages,
-                    FakeFs::new(cx.background()),
-                    &mut cx,
-                )
-                .await
-                .unwrap()
-            }
+        let project_b = guest_cx.spawn(|cx| {
+            Project::remote(
+                host_project_id,
+                self.client.clone(),
+                self.user_store.clone(),
+                self.project_store.clone(),
+                languages,
+                FakeFs::new(cx.background()),
+                cx,
+            )
         });
         host_cx.foreground().run_until_parked();
         host_project.update(host_cx, |project, cx| {
             project.respond_to_join_request(guest_user_id, true, cx)
         });
-        let project = project_b.await;
+        let project = project_b.await.unwrap();
         self.project = Some(project.clone());
         project
     }

crates/contacts_panel/src/contacts_panel.rs 🔗

@@ -13,15 +13,16 @@ use gpui::{
     impl_actions, impl_internal_actions,
     platform::CursorStyle,
     AppContext, ClipboardItem, Element, ElementBox, Entity, ModelHandle, MutableAppContext,
-    RenderContext, Subscription, View, ViewContext, ViewHandle, WeakViewHandle,
+    RenderContext, Subscription, View, ViewContext, ViewHandle, WeakModelHandle, WeakViewHandle,
 };
 use join_project_notification::JoinProjectNotification;
 use menu::{Confirm, SelectNext, SelectPrev};
+use project::{Project, ProjectStore};
 use serde::Deserialize;
 use settings::Settings;
-use std::sync::Arc;
+use std::{ops::DerefMut, sync::Arc};
 use theme::IconButton;
-use workspace::{sidebar::SidebarItem, JoinProject, Workspace};
+use workspace::{sidebar::SidebarItem, JoinProject, ToggleProjectPublic, Workspace};
 
 impl_actions!(
     contacts_panel,
@@ -37,13 +38,14 @@ enum Section {
     Offline,
 }
 
-#[derive(Clone, Debug)]
+#[derive(Clone)]
 enum ContactEntry {
     Header(Section),
     IncomingRequest(Arc<User>),
     OutgoingRequest(Arc<User>),
     Contact(Arc<Contact>),
-    ContactProject(Arc<Contact>, usize),
+    ContactProject(Arc<Contact>, usize, Option<WeakModelHandle<Project>>),
+    PrivateProject(WeakModelHandle<Project>),
 }
 
 #[derive(Clone)]
@@ -54,6 +56,7 @@ pub struct ContactsPanel {
     match_candidates: Vec<StringMatchCandidate>,
     list_state: ListState,
     user_store: ModelHandle<UserStore>,
+    project_store: ModelHandle<ProjectStore>,
     filter_editor: ViewHandle<Editor>,
     collapsed_sections: Vec<Section>,
     selection: Option<usize>,
@@ -89,6 +92,7 @@ pub fn init(cx: &mut MutableAppContext) {
 impl ContactsPanel {
     pub fn new(
         user_store: ModelHandle<UserStore>,
+        project_store: ModelHandle<ProjectStore>,
         workspace: WeakViewHandle<Workspace>,
         cx: &mut ViewContext<Self>,
     ) -> Self {
@@ -148,93 +152,88 @@ impl ContactsPanel {
             }
         });
 
-        cx.subscribe(&user_store, {
-            let user_store = user_store.downgrade();
-            move |_, _, event, cx| {
-                if let Some((workspace, user_store)) =
-                    workspace.upgrade(cx).zip(user_store.upgrade(cx))
-                {
-                    workspace.update(cx, |workspace, cx| match event {
-                        client::Event::Contact { user, kind } => match kind {
-                            ContactEventKind::Requested | ContactEventKind::Accepted => workspace
-                                .show_notification(user.id as usize, cx, |cx| {
-                                    cx.add_view(|cx| {
-                                        ContactNotification::new(
-                                            user.clone(),
-                                            *kind,
-                                            user_store,
-                                            cx,
-                                        )
-                                    })
-                                }),
-                            _ => {}
-                        },
+        cx.observe(&project_store, |this, _, cx| this.update_entries(cx))
+            .detach();
+
+        cx.subscribe(&user_store, move |_, user_store, event, cx| {
+            if let Some(workspace) = workspace.upgrade(cx) {
+                workspace.update(cx, |workspace, cx| match event {
+                    client::Event::Contact { user, kind } => match kind {
+                        ContactEventKind::Requested | ContactEventKind::Accepted => workspace
+                            .show_notification(user.id as usize, cx, |cx| {
+                                cx.add_view(|cx| {
+                                    ContactNotification::new(user.clone(), *kind, user_store, cx)
+                                })
+                            }),
                         _ => {}
-                    });
-                }
+                    },
+                    _ => {}
+                });
+            }
 
-                if let client::Event::ShowContacts = event {
-                    cx.emit(Event::Activate);
-                }
+            if let client::Event::ShowContacts = event {
+                cx.emit(Event::Activate);
             }
         })
         .detach();
 
-        let mut this = Self {
-            list_state: ListState::new(0, Orientation::Top, 1000., cx, {
-                move |this, ix, cx| {
-                    let theme = cx.global::<Settings>().theme.clone();
-                    let theme = &theme.contacts_panel;
-                    let current_user_id =
-                        this.user_store.read(cx).current_user().map(|user| user.id);
-                    let is_selected = this.selection == Some(ix);
-
-                    match &this.entries[ix] {
-                        ContactEntry::Header(section) => {
-                            let is_collapsed = this.collapsed_sections.contains(&section);
-                            Self::render_header(*section, theme, is_selected, is_collapsed, cx)
-                        }
-                        ContactEntry::IncomingRequest(user) => Self::render_contact_request(
-                            user.clone(),
-                            this.user_store.clone(),
-                            theme,
-                            true,
-                            is_selected,
-                            cx,
-                        ),
-                        ContactEntry::OutgoingRequest(user) => Self::render_contact_request(
-                            user.clone(),
-                            this.user_store.clone(),
-                            theme,
-                            false,
-                            is_selected,
-                            cx,
-                        ),
-                        ContactEntry::Contact(contact) => {
-                            Self::render_contact(contact.clone(), theme, is_selected)
-                        }
-                        ContactEntry::ContactProject(contact, project_ix) => {
-                            let is_last_project_for_contact =
-                                this.entries.get(ix + 1).map_or(true, |next| {
-                                    if let ContactEntry::ContactProject(next_contact, _) = next {
-                                        next_contact.user.id != contact.user.id
-                                    } else {
-                                        true
-                                    }
-                                });
-                            Self::render_contact_project(
-                                contact.clone(),
-                                current_user_id,
-                                *project_ix,
-                                theme,
-                                is_last_project_for_contact,
-                                is_selected,
-                                cx,
-                            )
-                        }
-                    }
+        let list_state = ListState::new(0, Orientation::Top, 1000., cx, move |this, ix, cx| {
+            let theme = cx.global::<Settings>().theme.clone();
+            let theme = &theme.contacts_panel;
+            let current_user_id = this.user_store.read(cx).current_user().map(|user| user.id);
+            let is_selected = this.selection == Some(ix);
+
+            match &this.entries[ix] {
+                ContactEntry::Header(section) => {
+                    let is_collapsed = this.collapsed_sections.contains(&section);
+                    Self::render_header(*section, theme, is_selected, is_collapsed, cx)
+                }
+                ContactEntry::IncomingRequest(user) => Self::render_contact_request(
+                    user.clone(),
+                    this.user_store.clone(),
+                    theme,
+                    true,
+                    is_selected,
+                    cx,
+                ),
+                ContactEntry::OutgoingRequest(user) => Self::render_contact_request(
+                    user.clone(),
+                    this.user_store.clone(),
+                    theme,
+                    false,
+                    is_selected,
+                    cx,
+                ),
+                ContactEntry::Contact(contact) => {
+                    Self::render_contact(&contact.user, theme, is_selected)
+                }
+                ContactEntry::ContactProject(contact, project_ix, _) => {
+                    let is_last_project_for_contact =
+                        this.entries.get(ix + 1).map_or(true, |next| {
+                            if let ContactEntry::ContactProject(next_contact, _, _) = next {
+                                next_contact.user.id != contact.user.id
+                            } else {
+                                true
+                            }
+                        });
+                    Self::render_contact_project(
+                        contact.clone(),
+                        current_user_id,
+                        *project_ix,
+                        theme,
+                        is_last_project_for_contact,
+                        is_selected,
+                        cx,
+                    )
+                }
+                ContactEntry::PrivateProject(project) => {
+                    Self::render_private_project(project.clone(), theme, is_selected, cx)
                 }
-            }),
+            }
+        });
+
+        let mut this = Self {
+            list_state,
             selection: None,
             collapsed_sections: Default::default(),
             entries: Default::default(),
@@ -242,6 +241,7 @@ impl ContactsPanel {
             filter_editor,
             _maintain_contacts: cx.observe(&user_store, |this, _, cx| this.update_entries(cx)),
             user_store,
+            project_store,
         };
         this.update_entries(cx);
         this
@@ -300,13 +300,9 @@ impl ContactsPanel {
         .boxed()
     }
 
-    fn render_contact(
-        contact: Arc<Contact>,
-        theme: &theme::ContactsPanel,
-        is_selected: bool,
-    ) -> ElementBox {
+    fn render_contact(user: &User, theme: &theme::ContactsPanel, is_selected: bool) -> ElementBox {
         Flex::row()
-            .with_children(contact.user.avatar.clone().map(|avatar| {
+            .with_children(user.avatar.clone().map(|avatar| {
                 Image::new(avatar)
                     .with_style(theme.contact_avatar)
                     .aligned()
@@ -315,7 +311,7 @@ impl ContactsPanel {
             }))
             .with_child(
                 Label::new(
-                    contact.user.github_login.clone(),
+                    user.github_login.clone(),
                     theme.contact_username.text.clone(),
                 )
                 .contained()
@@ -446,6 +442,84 @@ impl ContactsPanel {
         .boxed()
     }
 
+    fn render_private_project(
+        project: WeakModelHandle<Project>,
+        theme: &theme::ContactsPanel,
+        is_selected: bool,
+        cx: &mut RenderContext<Self>,
+    ) -> ElementBox {
+        let project = if let Some(project) = project.upgrade(cx.deref_mut()) {
+            project
+        } else {
+            return Empty::new().boxed();
+        };
+
+        let host_avatar_height = theme
+            .contact_avatar
+            .width
+            .or(theme.contact_avatar.height)
+            .unwrap_or(0.);
+
+        enum LocalProject {}
+        enum TogglePublic {}
+
+        let project_id = project.id();
+        MouseEventHandler::new::<LocalProject, _, _>(project_id, cx, |state, cx| {
+            let row = theme.project_row.style_for(state, is_selected);
+            let mut worktree_root_names = String::new();
+            let project = project.read(cx);
+            let is_public = project.is_public();
+            for tree in project.visible_worktrees(cx) {
+                if !worktree_root_names.is_empty() {
+                    worktree_root_names.push_str(", ");
+                }
+                worktree_root_names.push_str(tree.read(cx).root_name());
+            }
+
+            Flex::row()
+                .with_child(
+                    MouseEventHandler::new::<TogglePublic, _, _>(project_id, cx, |state, _| {
+                        if is_public {
+                            Empty::new().constrained()
+                        } else {
+                            render_icon_button(
+                                theme.private_button.style_for(state, false),
+                                "icons/lock-8.svg",
+                            )
+                            .aligned()
+                            .constrained()
+                        }
+                        .with_width(host_avatar_height)
+                        .boxed()
+                    })
+                    .with_cursor_style(if is_public {
+                        CursorStyle::default()
+                    } else {
+                        CursorStyle::PointingHand
+                    })
+                    .on_click(move |_, _, cx| {
+                        cx.dispatch_action(ToggleProjectPublic { project: None })
+                    })
+                    .boxed(),
+                )
+                .with_child(
+                    Label::new(worktree_root_names, row.name.text.clone())
+                        .aligned()
+                        .left()
+                        .contained()
+                        .with_style(row.name.container)
+                        .flex(1., false)
+                        .boxed(),
+                )
+                .constrained()
+                .with_height(theme.row_height)
+                .contained()
+                .with_style(row.container)
+                .boxed()
+        })
+        .boxed()
+    }
+
     fn render_contact_request(
         user: Arc<User>,
         user_store: ModelHandle<UserStore>,
@@ -557,6 +631,7 @@ impl ContactsPanel {
 
     fn update_entries(&mut self, cx: &mut ViewContext<Self>) {
         let user_store = self.user_store.read(cx);
+        let project_store = self.project_store.read(cx);
         let query = self.filter_editor.read(cx).text(cx);
         let executor = cx.background().clone();
 
@@ -629,20 +704,37 @@ impl ContactsPanel {
             }
         }
 
+        let current_user = user_store.current_user();
+
         let contacts = user_store.contacts();
         if !contacts.is_empty() {
+            // Always put the current user first.
             self.match_candidates.clear();
-            self.match_candidates
-                .extend(
-                    contacts
-                        .iter()
-                        .enumerate()
-                        .map(|(ix, contact)| StringMatchCandidate {
-                            id: ix,
-                            string: contact.user.github_login.clone(),
-                            char_bag: contact.user.github_login.chars().collect(),
-                        }),
-                );
+            self.match_candidates.reserve(contacts.len());
+            self.match_candidates.push(StringMatchCandidate {
+                id: 0,
+                string: Default::default(),
+                char_bag: Default::default(),
+            });
+            for (ix, contact) in contacts.iter().enumerate() {
+                let candidate = StringMatchCandidate {
+                    id: ix,
+                    string: contact.user.github_login.clone(),
+                    char_bag: contact.user.github_login.chars().collect(),
+                };
+                if current_user
+                    .as_ref()
+                    .map_or(false, |current_user| current_user.id == contact.user.id)
+                {
+                    self.match_candidates[0] = candidate;
+                } else {
+                    self.match_candidates.push(candidate);
+                }
+            }
+            if self.match_candidates[0].string.is_empty() {
+                self.match_candidates.remove(0);
+            }
+
             let matches = executor.block(match_strings(
                 &self.match_candidates,
                 &query,
@@ -666,16 +758,60 @@ impl ContactsPanel {
                         for mat in matches {
                             let contact = &contacts[mat.candidate_id];
                             self.entries.push(ContactEntry::Contact(contact.clone()));
-                            self.entries
-                                .extend(contact.projects.iter().enumerate().filter_map(
-                                    |(ix, project)| {
-                                        if project.worktree_root_names.is_empty() {
+
+                            let is_current_user = current_user
+                                .as_ref()
+                                .map_or(false, |user| user.id == contact.user.id);
+                            if is_current_user {
+                                let mut open_projects =
+                                    project_store.projects(cx).collect::<Vec<_>>();
+                                self.entries.extend(
+                                    contact.projects.iter().enumerate().filter_map(
+                                        |(ix, project)| {
+                                            let open_project = open_projects
+                                                .iter()
+                                                .position(|p| {
+                                                    p.read(cx).remote_id() == Some(project.id)
+                                                })
+                                                .map(|ix| open_projects.remove(ix).downgrade());
+                                            if project.worktree_root_names.is_empty() {
+                                                None
+                                            } else {
+                                                Some(ContactEntry::ContactProject(
+                                                    contact.clone(),
+                                                    ix,
+                                                    open_project,
+                                                ))
+                                            }
+                                        },
+                                    ),
+                                );
+                                self.entries.extend(open_projects.into_iter().filter_map(
+                                    |project| {
+                                        if project.read(cx).visible_worktrees(cx).next().is_none() {
                                             None
                                         } else {
-                                            Some(ContactEntry::ContactProject(contact.clone(), ix))
+                                            Some(ContactEntry::PrivateProject(project.downgrade()))
                                         }
                                     },
                                 ));
+                            } else {
+                                self.entries.extend(
+                                    contact.projects.iter().enumerate().filter_map(
+                                        |(ix, project)| {
+                                            if project.worktree_root_names.is_empty() {
+                                                None
+                                            } else {
+                                                Some(ContactEntry::ContactProject(
+                                                    contact.clone(),
+                                                    ix,
+                                                    None,
+                                                ))
+                                            }
+                                        },
+                                    ),
+                                );
+                            }
                         }
                     }
                 }
@@ -757,11 +893,18 @@ impl ContactsPanel {
                         let section = *section;
                         self.toggle_expanded(&ToggleExpanded(section), cx);
                     }
-                    ContactEntry::ContactProject(contact, project_index) => cx
-                        .dispatch_global_action(JoinProject {
-                            contact: contact.clone(),
-                            project_index: *project_index,
-                        }),
+                    ContactEntry::ContactProject(contact, project_index, open_project) => {
+                        if let Some(open_project) = open_project {
+                            workspace::activate_workspace_for_project(cx, |_, cx| {
+                                cx.model_id() == open_project.id()
+                            });
+                        } else {
+                            cx.dispatch_global_action(JoinProject {
+                                contact: contact.clone(),
+                                project_index: *project_index,
+                            })
+                        }
+                    }
                     _ => {}
                 }
             }
@@ -952,11 +1095,16 @@ impl PartialEq for ContactEntry {
                     return contact_1.user.id == contact_2.user.id;
                 }
             }
-            ContactEntry::ContactProject(contact_1, ix_1) => {
-                if let ContactEntry::ContactProject(contact_2, ix_2) = other {
+            ContactEntry::ContactProject(contact_1, ix_1, _) => {
+                if let ContactEntry::ContactProject(contact_2, ix_2, _) = other {
                     return contact_1.user.id == contact_2.user.id && ix_1 == ix_2;
                 }
             }
+            ContactEntry::PrivateProject(project_1) => {
+                if let ContactEntry::PrivateProject(project_2) = other {
+                    return project_1.id() == project_2.id();
+                }
+            }
         }
         false
     }
@@ -965,20 +1113,55 @@ impl PartialEq for ContactEntry {
 #[cfg(test)]
 mod tests {
     use super::*;
-    use client::{proto, test::FakeServer, Client};
-    use gpui::TestAppContext;
+    use client::{
+        proto,
+        test::{FakeHttpClient, FakeServer},
+        Client,
+    };
+    use gpui::{serde_json::json, TestAppContext};
     use language::LanguageRegistry;
-    use project::Project;
-    use theme::ThemeRegistry;
-    use workspace::AppState;
+    use project::{FakeFs, Project};
 
     #[gpui::test]
     async fn test_contact_panel(cx: &mut TestAppContext) {
-        let (app_state, server) = init(cx).await;
-        let project = Project::test(app_state.fs.clone(), [], cx).await;
-        let workspace = cx.add_view(0, |cx| Workspace::new(project, cx));
+        Settings::test_async(cx);
+        let current_user_id = 100;
+
+        let languages = Arc::new(LanguageRegistry::test());
+        let http_client = FakeHttpClient::with_404_response();
+        let client = Client::new(http_client.clone());
+        let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http_client, cx));
+        let project_store = cx.add_model(|_| ProjectStore::default());
+        let server = FakeServer::for_client(current_user_id, &client, &cx).await;
+        let fs = FakeFs::new(cx.background());
+        fs.insert_tree("/private_dir", json!({ "one.rs": "" }))
+            .await;
+        let project = cx.update(|cx| {
+            Project::local(
+                false,
+                client.clone(),
+                user_store.clone(),
+                project_store.clone(),
+                languages,
+                fs,
+                cx,
+            )
+        });
+        project
+            .update(cx, |project, cx| {
+                project.find_or_create_local_worktree("/private_dir", true, cx)
+            })
+            .await
+            .unwrap();
+
+        let workspace = cx.add_view(0, |cx| Workspace::new(project.clone(), cx));
         let panel = cx.add_view(0, |cx| {
-            ContactsPanel::new(app_state.user_store.clone(), workspace.downgrade(), cx)
+            ContactsPanel::new(
+                user_store.clone(),
+                project_store.clone(),
+                workspace.downgrade(),
+                cx,
+            )
         });
 
         let get_users_request = server.receive::<proto::GetUsers>().await.unwrap();
@@ -1001,6 +1184,11 @@ mod tests {
                         github_login: name.to_string(),
                         ..Default::default()
                     })
+                    .chain([proto::User {
+                        id: current_user_id,
+                        github_login: "the_current_user".to_string(),
+                        ..Default::default()
+                    }])
                     .collect(),
                 },
             )
@@ -1039,6 +1227,16 @@ mod tests {
                     should_notify: false,
                     projects: vec![],
                 },
+                proto::Contact {
+                    user_id: current_user_id,
+                    online: true,
+                    should_notify: false,
+                    projects: vec![proto::ProjectMetadata {
+                        id: 103,
+                        worktree_root_names: vec!["dir3".to_string()],
+                        guests: vec![3],
+                    }],
+                },
             ],
             ..Default::default()
         });
@@ -1052,6 +1250,9 @@ mod tests {
                 "  incoming user_one",
                 "  outgoing user_two",
                 "v Online",
+                "  the_current_user",
+                "    dir3",
+                "    🔒 private_dir",
                 "  user_four",
                 "    dir2",
                 "  user_three",
@@ -1133,12 +1334,24 @@ mod tests {
                     ContactEntry::Contact(contact) => {
                         format!("  {}", contact.user.github_login)
                     }
-                    ContactEntry::ContactProject(contact, project_ix) => {
+                    ContactEntry::ContactProject(contact, project_ix, _) => {
                         format!(
                             "    {}",
                             contact.projects[*project_ix].worktree_root_names.join(", ")
                         )
                     }
+                    ContactEntry::PrivateProject(project) => cx.read(|cx| {
+                        format!(
+                            "    🔒 {}",
+                            project
+                                .upgrade(cx)
+                                .unwrap()
+                                .read(cx)
+                                .worktree_root_names(cx)
+                                .collect::<Vec<_>>()
+                                .join(", ")
+                        )
+                    }),
                 };
 
                 if panel.selection == Some(ix) {
@@ -1150,28 +1363,4 @@ mod tests {
             entries
         })
     }
-
-    async fn init(cx: &mut TestAppContext) -> (Arc<AppState>, FakeServer) {
-        cx.update(|cx| cx.set_global(Settings::test(cx)));
-        let themes = ThemeRegistry::new((), cx.font_cache());
-        let fs = project::FakeFs::new(cx.background().clone());
-        let languages = Arc::new(LanguageRegistry::test());
-        let http_client = client::test::FakeHttpClient::with_404_response();
-        let mut client = Client::new(http_client.clone());
-        let server = FakeServer::for_client(100, &mut client, &cx).await;
-        let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http_client, cx));
-
-        (
-            Arc::new(AppState {
-                languages,
-                themes,
-                client,
-                user_store: user_store.clone(),
-                fs,
-                build_window_options: || Default::default(),
-                initialize_workspace: |_, _, _| {},
-            }),
-            server,
-        )
-    }
 }

crates/gpui/src/app.rs 🔗

@@ -4604,6 +4604,10 @@ impl<T: View> WeakViewHandle<T> {
         self.view_id
     }
 
+    pub fn window_id(&self) -> usize {
+        self.window_id
+    }
+
     pub fn upgrade(&self, cx: &impl UpgradeViewHandle) -> Option<ViewHandle<T>> {
         cx.upgrade_view_handle(self)
     }

crates/gpui/src/platform.rs 🔗

@@ -147,6 +147,12 @@ pub struct AppVersion {
     patch: usize,
 }
 
+impl Default for CursorStyle {
+    fn default() -> Self {
+        Self::Arrow
+    }
+}
+
 impl FromStr for AppVersion {
     type Err = anyhow::Error;
 

crates/project/src/project.rs 🔗

@@ -59,6 +59,11 @@ pub trait Item: Entity {
     fn entry_id(&self, cx: &AppContext) -> Option<ProjectEntryId>;
 }
 
+#[derive(Default)]
+pub struct ProjectStore {
+    projects: Vec<WeakModelHandle<Project>>,
+}
+
 pub struct Project {
     worktrees: Vec<WorktreeHandle>,
     active_entry: Option<ProjectEntryId>,
@@ -75,6 +80,7 @@ pub struct Project {
     next_entry_id: Arc<AtomicUsize>,
     next_diagnostic_group_id: usize,
     user_store: ModelHandle<UserStore>,
+    project_store: ModelHandle<ProjectStore>,
     fs: Arc<dyn Fs>,
     client_state: ProjectClientState,
     collaborators: HashMap<PeerId, Collaborator>,
@@ -121,6 +127,7 @@ enum ProjectClientState {
         remote_id_tx: watch::Sender<Option<u64>>,
         remote_id_rx: watch::Receiver<Option<u64>>,
         public_tx: watch::Sender<bool>,
+        public_rx: watch::Receiver<bool>,
         _maintain_remote_id_task: Task<Option<()>>,
     },
     Remote {
@@ -309,15 +316,17 @@ impl Project {
         public: bool,
         client: Arc<Client>,
         user_store: ModelHandle<UserStore>,
+        project_store: ModelHandle<ProjectStore>,
         languages: Arc<LanguageRegistry>,
         fs: Arc<dyn Fs>,
         cx: &mut MutableAppContext,
     ) -> ModelHandle<Self> {
         cx.add_model(|cx: &mut ModelContext<Self>| {
-            let (public_tx, mut public_rx) = watch::channel_with(public);
+            let (public_tx, public_rx) = watch::channel_with(public);
             let (remote_id_tx, remote_id_rx) = watch::channel();
             let _maintain_remote_id_task = cx.spawn_weak({
                 let mut status_rx = client.clone().status();
+                let mut public_rx = public_rx.clone();
                 move |this, mut cx| async move {
                     loop {
                         select_biased! {
@@ -336,6 +345,9 @@ impl Project {
                 }
             });
 
+            let handle = cx.weak_handle();
+            project_store.update(cx, |store, cx| store.add(handle, cx));
+
             let (opened_buffer_tx, opened_buffer_rx) = watch::channel();
             Self {
                 worktrees: Default::default(),
@@ -350,6 +362,7 @@ impl Project {
                     remote_id_tx,
                     remote_id_rx,
                     public_tx,
+                    public_rx,
                     _maintain_remote_id_task,
                 },
                 opened_buffer: (Rc::new(RefCell::new(opened_buffer_tx)), opened_buffer_rx),
@@ -358,6 +371,7 @@ impl Project {
                 languages,
                 client,
                 user_store,
+                project_store,
                 fs,
                 next_entry_id: Default::default(),
                 next_diagnostic_group_id: Default::default(),
@@ -376,9 +390,10 @@ impl Project {
         remote_id: u64,
         client: Arc<Client>,
         user_store: ModelHandle<UserStore>,
+        project_store: ModelHandle<ProjectStore>,
         languages: Arc<LanguageRegistry>,
         fs: Arc<dyn Fs>,
-        cx: &mut AsyncAppContext,
+        mut cx: AsyncAppContext,
     ) -> Result<ModelHandle<Self>, JoinProjectError> {
         client.authenticate_and_connect(true, &cx).await?;
 
@@ -418,6 +433,9 @@ impl Project {
 
         let (opened_buffer_tx, opened_buffer_rx) = watch::channel();
         let this = cx.add_model(|cx: &mut ModelContext<Self>| {
+            let handle = cx.weak_handle();
+            project_store.update(cx, |store, cx| store.add(handle, cx));
+
             let mut this = Self {
                 worktrees: Vec::new(),
                 loading_buffers: Default::default(),
@@ -428,6 +446,7 @@ impl Project {
                 collaborators: Default::default(),
                 languages,
                 user_store: user_store.clone(),
+                project_store,
                 fs,
                 next_entry_id: Default::default(),
                 next_diagnostic_group_id: Default::default(),
@@ -488,15 +507,15 @@ impl Project {
             .map(|peer| peer.user_id)
             .collect();
         user_store
-            .update(cx, |user_store, cx| user_store.get_users(user_ids, cx))
+            .update(&mut cx, |user_store, cx| user_store.get_users(user_ids, cx))
             .await?;
         let mut collaborators = HashMap::default();
         for message in response.collaborators {
-            let collaborator = Collaborator::from_proto(message, &user_store, cx).await?;
+            let collaborator = Collaborator::from_proto(message, &user_store, &mut cx).await?;
             collaborators.insert(collaborator.peer_id, collaborator);
         }
 
-        this.update(cx, |this, _| {
+        this.update(&mut cx, |this, _| {
             this.collaborators = collaborators;
         });
 
@@ -513,7 +532,10 @@ impl Project {
         let http_client = client::test::FakeHttpClient::with_404_response();
         let client = client::Client::new(http_client.clone());
         let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http_client, cx));
-        let project = cx.update(|cx| Project::local(true, client, user_store, languages, fs, cx));
+        let project_store = cx.add_model(|_| ProjectStore::default());
+        let project = cx.update(|cx| {
+            Project::local(true, client, user_store, project_store, languages, fs, cx)
+        });
         for path in root_paths {
             let (tree, _) = project
                 .update(cx, |project, cx| {
@@ -608,11 +630,10 @@ impl Project {
         }
     }
 
-    pub fn is_public(&mut self) -> bool {
-        if let ProjectClientState::Local { public_tx, .. } = &mut self.client_state {
-            *public_tx.borrow()
-        } else {
-            true
+    pub fn is_public(&self) -> bool {
+        match &self.client_state {
+            ProjectClientState::Local { public_rx, .. } => *public_rx.borrow(),
+            ProjectClientState::Remote { .. } => true,
         }
     }
 
@@ -752,6 +773,11 @@ impl Project {
         })
     }
 
+    pub fn worktree_root_names<'a>(&'a self, cx: &'a AppContext) -> impl Iterator<Item = &'a str> {
+        self.visible_worktrees(cx)
+            .map(|tree| tree.read(cx).root_name())
+    }
+
     pub fn worktree_for_id(
         &self,
         id: WorktreeId,
@@ -779,6 +805,20 @@ impl Project {
             .map(|worktree| worktree.read(cx).id())
     }
 
+    pub fn contains_paths(&self, paths: &[PathBuf], cx: &AppContext) -> bool {
+        paths.iter().all(|path| self.contains_path(&path, cx))
+    }
+
+    pub fn contains_path(&self, path: &Path, cx: &AppContext) -> bool {
+        for worktree in self.worktrees(cx) {
+            let worktree = worktree.read(cx).as_local();
+            if worktree.map_or(false, |w| w.contains_abs_path(path)) {
+                return true;
+            }
+        }
+        false
+    }
+
     pub fn create_entry(
         &mut self,
         project_path: impl Into<ProjectPath>,
@@ -5154,6 +5194,42 @@ impl Project {
     }
 }
 
+impl ProjectStore {
+    pub fn projects<'a>(
+        &'a self,
+        cx: &'a AppContext,
+    ) -> impl 'a + Iterator<Item = ModelHandle<Project>> {
+        self.projects
+            .iter()
+            .filter_map(|project| project.upgrade(cx))
+    }
+
+    fn add(&mut self, project: WeakModelHandle<Project>, cx: &mut ModelContext<Self>) {
+        if let Err(ix) = self
+            .projects
+            .binary_search_by_key(&project.id(), WeakModelHandle::id)
+        {
+            self.projects.insert(ix, project);
+        }
+        cx.notify();
+    }
+
+    fn prune(&mut self, cx: &mut ModelContext<Self>) {
+        let mut did_change = false;
+        self.projects.retain(|project| {
+            if project.is_upgradable(cx) {
+                true
+            } else {
+                did_change = true;
+                false
+            }
+        });
+        if did_change {
+            cx.notify();
+        }
+    }
+}
+
 impl WorktreeHandle {
     pub fn upgrade(&self, cx: &AppContext) -> Option<ModelHandle<Worktree>> {
         match self {
@@ -5232,10 +5308,16 @@ impl<'a> Iterator for CandidateSetIter<'a> {
     }
 }
 
+impl Entity for ProjectStore {
+    type Event = ();
+}
+
 impl Entity for Project {
     type Event = Event;
 
-    fn release(&mut self, _: &mut gpui::MutableAppContext) {
+    fn release(&mut self, cx: &mut gpui::MutableAppContext) {
+        self.project_store.update(cx, ProjectStore::prune);
+
         match &self.client_state {
             ProjectClientState::Local { remote_id_rx, .. } => {
                 if let Some(project_id) = *remote_id_rx.borrow() {

crates/theme/src/theme.rs 🔗

@@ -281,6 +281,7 @@ pub struct ContactsPanel {
     pub contact_button_spacing: f32,
     pub disabled_contact_button: IconButton,
     pub tree_branch: Interactive<TreeBranch>,
+    pub private_button: Interactive<IconButton>,
     pub section_icon_size: f32,
     pub invite_row: Interactive<ContainedLabel>,
 }

crates/workspace/src/waiting_room.rs 🔗

@@ -85,9 +85,10 @@ impl WaitingRoom {
                         project_id,
                         app_state.client.clone(),
                         app_state.user_store.clone(),
+                        app_state.project_store.clone(),
                         app_state.languages.clone(),
                         app_state.fs.clone(),
-                        &mut cx,
+                        cx.clone(),
                     )
                     .await;
 

crates/workspace/src/workspace.rs 🔗

@@ -17,19 +17,20 @@ use gpui::{
     color::Color,
     elements::*,
     geometry::{rect::RectF, vector::vec2f, PathBuilder},
-    impl_internal_actions,
+    impl_actions, impl_internal_actions,
     json::{self, ToJson},
     platform::{CursorStyle, WindowOptions},
     AnyModelHandle, AnyViewHandle, AppContext, AsyncAppContext, Border, Entity, ImageData,
-    ModelHandle, MutableAppContext, PathPromptOptions, PromptLevel, RenderContext, Task, View,
-    ViewContext, ViewHandle, WeakViewHandle,
+    ModelContext, ModelHandle, MutableAppContext, PathPromptOptions, PromptLevel, RenderContext,
+    Task, View, ViewContext, ViewHandle, WeakModelHandle, WeakViewHandle,
 };
 use language::LanguageRegistry;
 use log::error;
 pub use pane::*;
 pub use pane_group::*;
 use postage::prelude::Stream;
-use project::{fs, Fs, Project, ProjectEntryId, ProjectPath, Worktree, WorktreeId};
+use project::{fs, Fs, Project, ProjectEntryId, ProjectPath, ProjectStore, Worktree, WorktreeId};
+use serde::Deserialize;
 use settings::Settings;
 use sidebar::{Side, Sidebar, SidebarButtons, ToggleSidebarItem, ToggleSidebarItemFocus};
 use smallvec::SmallVec;
@@ -98,6 +99,12 @@ pub struct OpenPaths {
     pub paths: Vec<PathBuf>,
 }
 
+#[derive(Clone, Deserialize)]
+pub struct ToggleProjectPublic {
+    #[serde(skip_deserializing)]
+    pub project: Option<WeakModelHandle<Project>>,
+}
+
 #[derive(Clone)]
 pub struct ToggleFollow(pub PeerId);
 
@@ -116,6 +123,7 @@ impl_internal_actions!(
         RemoveFolderFromProject
     ]
 );
+impl_actions!(workspace, [ToggleProjectPublic]);
 
 pub fn init(app_state: Arc<AppState>, cx: &mut MutableAppContext) {
     pane::init(cx);
@@ -160,6 +168,7 @@ pub fn init(app_state: Arc<AppState>, cx: &mut MutableAppContext) {
     cx.add_async_action(Workspace::save_all);
     cx.add_action(Workspace::add_folder_to_project);
     cx.add_action(Workspace::remove_folder_from_project);
+    cx.add_action(Workspace::toggle_project_public);
     cx.add_action(
         |workspace: &mut Workspace, _: &Unfollow, cx: &mut ViewContext<Workspace>| {
             let pane = workspace.active_pane().clone();
@@ -222,6 +231,7 @@ pub struct AppState {
     pub themes: Arc<ThemeRegistry>,
     pub client: Arc<client::Client>,
     pub user_store: ModelHandle<client::UserStore>,
+    pub project_store: ModelHandle<ProjectStore>,
     pub fs: Arc<dyn fs::Fs>,
     pub build_window_options: fn() -> WindowOptions<'static>,
     pub initialize_workspace: fn(&mut Workspace, &Arc<AppState>, &mut ViewContext<Workspace>),
@@ -682,6 +692,7 @@ impl AppState {
         let languages = Arc::new(LanguageRegistry::test());
         let http_client = client::test::FakeHttpClient::with_404_response();
         let client = Client::new(http_client.clone());
+        let project_store = cx.add_model(|_| ProjectStore::default());
         let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http_client, cx));
         let themes = ThemeRegistry::new((), cx.font_cache().clone());
         Arc::new(Self {
@@ -690,6 +701,7 @@ impl AppState {
             fs,
             languages,
             user_store,
+            project_store,
             initialize_workspace: |_, _, _| {},
             build_window_options: || Default::default(),
         })
@@ -837,10 +849,7 @@ impl Workspace {
             _observe_current_user,
         };
         this.project_remote_id_changed(this.project.read(cx).remote_id(), cx);
-
-        cx.defer(|this, cx| {
-            this.update_window_title(cx);
-        });
+        cx.defer(|this, cx| this.update_window_title(cx));
 
         this
     }
@@ -876,20 +885,6 @@ impl Workspace {
         self.project.read(cx).worktrees(cx)
     }
 
-    pub fn contains_paths(&self, paths: &[PathBuf], cx: &AppContext) -> bool {
-        paths.iter().all(|path| self.contains_path(&path, cx))
-    }
-
-    pub fn contains_path(&self, path: &Path, cx: &AppContext) -> bool {
-        for worktree in self.worktrees(cx) {
-            let worktree = worktree.read(cx).as_local();
-            if worktree.map_or(false, |w| w.contains_abs_path(path)) {
-                return true;
-            }
-        }
-        false
-    }
-
     pub fn worktree_scans_complete(&self, cx: &AppContext) -> impl Future<Output = ()> + 'static {
         let futures = self
             .worktrees(cx)
@@ -1054,6 +1049,23 @@ impl Workspace {
             .update(cx, |project, cx| project.remove_worktree(*worktree_id, cx));
     }
 
+    fn toggle_project_public(&mut self, action: &ToggleProjectPublic, cx: &mut ViewContext<Self>) {
+        let project = if let Some(project) = action.project {
+            if let Some(project) = project.upgrade(cx) {
+                project
+            } else {
+                return;
+            }
+        } else {
+            self.project.clone()
+        };
+
+        project.update(cx, |project, _| {
+            let is_public = project.is_public();
+            project.set_public(!is_public);
+        });
+    }
+
     fn project_path_for_path(
         &self,
         abs_path: &Path,
@@ -1668,8 +1680,15 @@ impl Workspace {
     }
 
     fn render_titlebar(&self, theme: &Theme, cx: &mut RenderContext<Self>) -> ElementBox {
+        let project = &self.project.read(cx);
+        let replica_id = project.replica_id();
         let mut worktree_root_names = String::new();
-        self.worktree_root_names(&mut worktree_root_names, cx);
+        for (i, name) in project.worktree_root_names(cx).enumerate() {
+            if i > 0 {
+                worktree_root_names.push_str(", ");
+            }
+            worktree_root_names.push_str(name);
+        }
 
         ConstrainedBox::new(
             Container::new(
@@ -1686,7 +1705,7 @@ impl Workspace {
                                 .with_children(self.render_collaborators(theme, cx))
                                 .with_children(self.render_current_user(
                                     self.user_store.read(cx).current_user().as_ref(),
-                                    self.project.read(cx).replica_id(),
+                                    replica_id,
                                     theme,
                                     cx,
                                 ))
@@ -1714,6 +1733,7 @@ impl Workspace {
 
     fn update_window_title(&mut self, cx: &mut ViewContext<Self>) {
         let mut title = String::new();
+        let project = self.project().read(cx);
         if let Some(path) = self.active_item(cx).and_then(|item| item.project_path(cx)) {
             let filename = path
                 .path
@@ -1721,8 +1741,7 @@ impl Workspace {
                 .map(|s| s.to_string_lossy())
                 .or_else(|| {
                     Some(Cow::Borrowed(
-                        self.project()
-                            .read(cx)
+                        project
                             .worktree_for_id(path.worktree_id, cx)?
                             .read(cx)
                             .root_name(),
@@ -1733,22 +1752,18 @@ impl Workspace {
                 title.push_str(" — ");
             }
         }
-        self.worktree_root_names(&mut title, cx);
+        for (i, name) in project.worktree_root_names(cx).enumerate() {
+            if i > 0 {
+                title.push_str(", ");
+            }
+            title.push_str(name);
+        }
         if title.is_empty() {
             title = "empty project".to_string();
         }
         cx.set_window_title(&title);
     }
 
-    fn worktree_root_names(&self, string: &mut String, cx: &mut MutableAppContext) {
-        for (i, worktree) in self.project.read(cx).visible_worktrees(cx).enumerate() {
-            if i != 0 {
-                string.push_str(", ");
-            }
-            string.push_str(worktree.read(cx).root_name());
-        }
-    }
-
     fn render_collaborators(&self, theme: &Theme, cx: &mut RenderContext<Self>) -> Vec<ElementBox> {
         let mut collaborators = self
             .project
@@ -2365,6 +2380,22 @@ fn open(_: &Open, cx: &mut MutableAppContext) {
 
 pub struct WorkspaceCreated(WeakViewHandle<Workspace>);
 
+pub fn activate_workspace_for_project(
+    cx: &mut MutableAppContext,
+    predicate: impl Fn(&mut Project, &mut ModelContext<Project>) -> bool,
+) -> Option<ViewHandle<Workspace>> {
+    for window_id in cx.window_ids().collect::<Vec<_>>() {
+        if let Some(workspace_handle) = cx.root_view::<Workspace>(window_id) {
+            let project = workspace_handle.read(cx).project.clone();
+            if project.update(cx, &predicate) {
+                cx.activate_window(window_id);
+                return Some(workspace_handle);
+            }
+        }
+    }
+    None
+}
+
 pub fn open_paths(
     abs_paths: &[PathBuf],
     app_state: &Arc<AppState>,
@@ -2376,22 +2407,8 @@ pub fn open_paths(
     log::info!("open paths {:?}", abs_paths);
 
     // Open paths in existing workspace if possible
-    let mut existing = None;
-    for window_id in cx.window_ids().collect::<Vec<_>>() {
-        if let Some(workspace_handle) = cx.root_view::<Workspace>(window_id) {
-            if workspace_handle.update(cx, |workspace, cx| {
-                if workspace.contains_paths(abs_paths, cx.as_ref()) {
-                    cx.activate_window(window_id);
-                    existing = Some(workspace_handle.clone());
-                    true
-                } else {
-                    false
-                }
-            }) {
-                break;
-            }
-        }
-    }
+    let existing =
+        activate_workspace_for_project(cx, |project, cx| project.contains_paths(abs_paths, cx));
 
     let app_state = app_state.clone();
     let abs_paths = abs_paths.to_vec();
@@ -2410,6 +2427,7 @@ pub fn open_paths(
                         false,
                         app_state.client.clone(),
                         app_state.user_store.clone(),
+                        app_state.project_store.clone(),
                         app_state.languages.clone(),
                         app_state.fs.clone(),
                         cx,
@@ -2467,6 +2485,7 @@ fn open_new(app_state: &Arc<AppState>, cx: &mut MutableAppContext) {
                 false,
                 app_state.client.clone(),
                 app_state.user_store.clone(),
+                app_state.project_store.clone(),
                 app_state.languages.clone(),
                 app_state.fs.clone(),
                 cx,

crates/zed/src/main.rs 🔗

@@ -23,7 +23,7 @@ use gpui::{executor::Background, App, AssetSource, AsyncAppContext, Task};
 use isahc::{config::Configurable, AsyncBody, Request};
 use log::LevelFilter;
 use parking_lot::Mutex;
-use project::Fs;
+use project::{Fs, ProjectStore};
 use serde_json::json;
 use settings::{self, KeymapFileContent, Settings, SettingsFileContent};
 use smol::process::Command;
@@ -136,6 +136,7 @@ fn main() {
         let client = client::Client::new(http.clone());
         let mut languages = languages::build_language_registry(login_shell_env_loaded);
         let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http.clone(), cx));
+        let project_store = cx.add_model(|_| ProjectStore::default());
 
         context_menu::init(cx);
         auto_update::init(http, client::ZED_SERVER_URL.clone(), cx);
@@ -195,6 +196,7 @@ fn main() {
             themes,
             client: client.clone(),
             user_store,
+            project_store,
             fs,
             build_window_options,
             initialize_workspace,

crates/zed/src/zed.rs 🔗

@@ -181,7 +181,12 @@ pub fn initialize_workspace(
 
     let project_panel = ProjectPanel::new(workspace.project().clone(), cx);
     let contact_panel = cx.add_view(|cx| {
-        ContactsPanel::new(app_state.user_store.clone(), workspace.weak_handle(), cx)
+        ContactsPanel::new(
+            app_state.user_store.clone(),
+            app_state.project_store.clone(),
+            workspace.weak_handle(),
+            cx,
+        )
     });
 
     workspace.left_sidebar().update(cx, |sidebar, cx| {
@@ -298,6 +303,7 @@ fn open_config_file(
                                 false,
                                 app_state.client.clone(),
                                 app_state.user_store.clone(),
+                                app_state.project_store.clone(),
                                 app_state.languages.clone(),
                                 app_state.fs.clone(),
                                 cx,

styles/src/styleTree/contactsPanel.ts 🔗

@@ -68,6 +68,11 @@ export default function contactsPanel(theme: Theme) {
       buttonWidth: 8,
       iconWidth: 8,
     },
+    privateButton: {
+      iconWidth: 8,
+      color: iconColor(theme, "primary"),
+      buttonWidth: 8,
+    },
     rowHeight: 28,
     sectionIconSize: 8,
     headerRow: {