diff --git a/assets/icons/lock-8.svg b/assets/icons/lock-8.svg new file mode 100644 index 0000000000..c98340b93a --- /dev/null +++ b/assets/icons/lock-8.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs index 0fc0f97949..51da1f4c1c 100644 --- a/crates/client/src/client.rs +++ b/crates/client/src/client.rs @@ -67,17 +67,23 @@ pub struct Client { peer: Arc, http: Arc, state: RwLock, - authenticate: + + #[cfg(any(test, feature = "test-support"))] + authenticate: RwLock< Option Task>>>, - establish_connection: Option< - Box< - dyn 'static - + Send - + Sync - + Fn( - &Credentials, - &AsyncAppContext, - ) -> Task>, + >, + #[cfg(any(test, feature = "test-support"))] + establish_connection: RwLock< + Option< + Box< + dyn 'static + + Send + + Sync + + Fn( + &Credentials, + &AsyncAppContext, + ) -> Task>, + >, >, >, } @@ -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(&mut self, authenticate: F) -> &mut Self + pub fn override_authenticate(&self, authenticate: F) -> &Self where F: 'static + Send + Sync + Fn(&AsyncAppContext) -> Task>, { - 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(&mut self, connect: F) -> &mut Self + pub fn override_establish_connection(&self, connect: F) -> &Self where F: 'static + Send + Sync + Fn(&Credentials, &AsyncAppContext) -> Task>, { - 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, cx: &AsyncAppContext) -> Task> { - 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> { - 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( diff --git a/crates/client/src/test.rs b/crates/client/src/test.rs index face7db16e..a809bd2769 100644 --- a/crates/client/src/test.rs +++ b/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: &Arc, 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| { diff --git a/crates/collab/src/integration_tests.rs b/crates/collab/src/integration_tests.rs index 39964be671..2ba324eed8 100644 --- a/crates/collab/src/integration_tests.rs +++ b/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, + pub project_store: ModelHandle, language_registry: Arc, project: Option>, buffers: HashSet>, @@ -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 } diff --git a/crates/contacts_panel/src/contacts_panel.rs b/crates/contacts_panel/src/contacts_panel.rs index 13485e96f2..ffa3300e70 100644 --- a/crates/contacts_panel/src/contacts_panel.rs +++ b/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), OutgoingRequest(Arc), Contact(Arc), - ContactProject(Arc, usize), + ContactProject(Arc, usize, Option>), + PrivateProject(WeakModelHandle), } #[derive(Clone)] @@ -54,6 +56,7 @@ pub struct ContactsPanel { match_candidates: Vec, list_state: ListState, user_store: ModelHandle, + project_store: ModelHandle, filter_editor: ViewHandle, collapsed_sections: Vec
, selection: Option, @@ -89,6 +92,7 @@ pub fn init(cx: &mut MutableAppContext) { impl ContactsPanel { pub fn new( user_store: ModelHandle, + project_store: ModelHandle, workspace: WeakViewHandle, cx: &mut ViewContext, ) -> 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(); - if let client::Event::ShowContacts = event { - cx.emit(Event::Activate); - } + 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); } }) .detach(); - let mut this = Self { - list_state: ListState::new(0, Orientation::Top, 1000., cx, { - move |this, ix, cx| { - let theme = cx.global::().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); + let list_state = ListState::new(0, Orientation::Top, 1000., cx, move |this, ix, cx| { + let theme = cx.global::().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(§ion); - 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, - ) - } - } + match &this.entries[ix] { + ContactEntry::Header(section) => { + let is_collapsed = this.collapsed_sections.contains(§ion); + 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, - 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, + theme: &theme::ContactsPanel, + is_selected: bool, + cx: &mut RenderContext, + ) -> 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::(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::(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_store: ModelHandle, @@ -557,6 +631,7 @@ impl ContactsPanel { fn update_entries(&mut self, cx: &mut ViewContext) { 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::>(); + 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::().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::>() + .join(", ") + ) + }), }; if panel.selection == Some(ix) { @@ -1150,28 +1363,4 @@ mod tests { entries }) } - - async fn init(cx: &mut TestAppContext) -> (Arc, 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, - ) - } } diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index 19de98b3ce..bcdce61a05 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -4604,6 +4604,10 @@ impl WeakViewHandle { self.view_id } + pub fn window_id(&self) -> usize { + self.window_id + } + pub fn upgrade(&self, cx: &impl UpgradeViewHandle) -> Option> { cx.upgrade_view_handle(self) } diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index 16a6481a43..10de12fbe1 100644 --- a/crates/gpui/src/platform.rs +++ b/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; diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 58dc2ced20..9f8ba97ea8 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -59,6 +59,11 @@ pub trait Item: Entity { fn entry_id(&self, cx: &AppContext) -> Option; } +#[derive(Default)] +pub struct ProjectStore { + projects: Vec>, +} + pub struct Project { worktrees: Vec, active_entry: Option, @@ -75,6 +80,7 @@ pub struct Project { next_entry_id: Arc, next_diagnostic_group_id: usize, user_store: ModelHandle, + project_store: ModelHandle, fs: Arc, client_state: ProjectClientState, collaborators: HashMap, @@ -121,6 +127,7 @@ enum ProjectClientState { remote_id_tx: watch::Sender>, remote_id_rx: watch::Receiver>, public_tx: watch::Sender, + public_rx: watch::Receiver, _maintain_remote_id_task: Task>, }, Remote { @@ -309,15 +316,17 @@ impl Project { public: bool, client: Arc, user_store: ModelHandle, + project_store: ModelHandle, languages: Arc, fs: Arc, cx: &mut MutableAppContext, ) -> ModelHandle { cx.add_model(|cx: &mut ModelContext| { - 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, user_store: ModelHandle, + project_store: ModelHandle, languages: Arc, fs: Arc, - cx: &mut AsyncAppContext, + mut cx: AsyncAppContext, ) -> Result, 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| { + 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 { + 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, @@ -5154,6 +5194,42 @@ impl Project { } } +impl ProjectStore { + pub fn projects<'a>( + &'a self, + cx: &'a AppContext, + ) -> impl 'a + Iterator> { + self.projects + .iter() + .filter_map(|project| project.upgrade(cx)) + } + + fn add(&mut self, project: WeakModelHandle, cx: &mut ModelContext) { + 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) { + 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> { 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() { diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs index 3639127633..a9bd2b2b48 100644 --- a/crates/theme/src/theme.rs +++ b/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, + pub private_button: Interactive, pub section_icon_size: f32, pub invite_row: Interactive, } diff --git a/crates/workspace/src/waiting_room.rs b/crates/workspace/src/waiting_room.rs index 3720d9ec43..c3d1e3c7e6 100644 --- a/crates/workspace/src/waiting_room.rs +++ b/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; diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 1a38cd4866..0889077a1e 100644 --- a/crates/workspace/src/workspace.rs +++ b/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, } +#[derive(Clone, Deserialize)] +pub struct ToggleProjectPublic { + #[serde(skip_deserializing)] + pub project: Option>, +} + #[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, cx: &mut MutableAppContext) { pane::init(cx); @@ -160,6 +168,7 @@ pub fn init(app_state: Arc, 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| { let pane = workspace.active_pane().clone(); @@ -222,6 +231,7 @@ pub struct AppState { pub themes: Arc, pub client: Arc, pub user_store: ModelHandle, + pub project_store: ModelHandle, pub fs: Arc, pub build_window_options: fn() -> WindowOptions<'static>, pub initialize_workspace: fn(&mut Workspace, &Arc, &mut ViewContext), @@ -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 + '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) { + 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) -> 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) { 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) -> Vec { let mut collaborators = self .project @@ -2365,6 +2380,22 @@ fn open(_: &Open, cx: &mut MutableAppContext) { pub struct WorkspaceCreated(WeakViewHandle); +pub fn activate_workspace_for_project( + cx: &mut MutableAppContext, + predicate: impl Fn(&mut Project, &mut ModelContext) -> bool, +) -> Option> { + for window_id in cx.window_ids().collect::>() { + if let Some(workspace_handle) = cx.root_view::(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, @@ -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::>() { - if let Some(workspace_handle) = cx.root_view::(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, 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, diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 20d467f276..1427343e4b 100644 --- a/crates/zed/src/main.rs +++ b/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, diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 63b9bb5fea..5261d191a3 100644 --- a/crates/zed/src/zed.rs +++ b/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, diff --git a/styles/src/styleTree/contactsPanel.ts b/styles/src/styleTree/contactsPanel.ts index d52f7f92b2..5253a1185c 100644 --- a/styles/src/styleTree/contactsPanel.ts +++ b/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: {