Remove projects from contact updates

Co-Authored-By: Nathan Sobo <nathan@zed.dev>
This commit is contained in:
Antonio Scandurra 2022-09-29 19:40:36 +02:00
parent 1898e813f5
commit b35e8f0164
7 changed files with 45 additions and 1339 deletions

View File

@ -1,7 +1,7 @@
use super::{http::HttpClient, proto, Client, Status, TypedEnvelope};
use crate::incoming_call::IncomingCall;
use anyhow::{anyhow, Context, Result};
use collections::{hash_map::Entry, BTreeSet, HashMap, HashSet};
use collections::{hash_map::Entry, HashMap, HashSet};
use futures::{channel::mpsc, future, AsyncReadExt, Future, StreamExt};
use gpui::{AsyncAppContext, Entity, ImageData, ModelContext, ModelHandle, Task};
use postage::{sink::Sink, watch};
@ -40,14 +40,6 @@ impl Eq for User {}
pub struct Contact {
pub user: Arc<User>,
pub online: bool,
pub projects: Vec<ProjectMetadata>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct ProjectMetadata {
pub id: u64,
pub visible_worktree_root_names: Vec<String>,
pub guests: BTreeSet<Arc<User>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@ -290,7 +282,6 @@ impl UserStore {
let mut user_ids = HashSet::default();
for contact in &message.contacts {
user_ids.insert(contact.user_id);
user_ids.extend(contact.projects.iter().flat_map(|w| &w.guests).copied());
}
user_ids.extend(message.incoming_requests.iter().map(|req| req.requester_id));
user_ids.extend(message.outgoing_requests.iter());
@ -688,34 +679,11 @@ impl Contact {
user_store.get_user(contact.user_id, cx)
})
.await?;
let mut projects = Vec::new();
for project in contact.projects {
let mut guests = BTreeSet::new();
for participant_id in project.guests {
guests.insert(
user_store
.update(cx, |user_store, cx| user_store.get_user(participant_id, cx))
.await?,
);
}
projects.push(ProjectMetadata {
id: project.id,
visible_worktree_root_names: project.visible_worktree_root_names.clone(),
guests,
});
}
Ok(Self {
user,
online: contact.online,
projects,
})
}
pub fn non_empty_projects(&self) -> impl Iterator<Item = &ProjectMetadata> {
self.projects
.iter()
.filter(|project| !project.visible_worktree_root_names.is_empty())
}
}
async fn fetch_avatar(http: &dyn HttpClient, url: &str) -> Result<Arc<ImageData>> {

View File

@ -8,7 +8,7 @@ use anyhow::anyhow;
use call::Room;
use client::{
self, proto, test::FakeHttpClient, Channel, ChannelDetails, ChannelList, Client, Connection,
Credentials, EstablishConnectionError, ProjectMetadata, UserStore, RECEIVE_TIMEOUT,
Credentials, EstablishConnectionError, UserStore, RECEIVE_TIMEOUT,
};
use collections::{BTreeMap, HashMap, HashSet};
use editor::{
@ -731,294 +731,6 @@ async fn test_cancel_join_request(
);
}
#[gpui::test(iterations = 10)]
async fn test_offline_projects(
deterministic: Arc<Deterministic>,
cx_a: &mut TestAppContext,
cx_b: &mut TestAppContext,
cx_c: &mut TestAppContext,
) {
cx_a.foreground().forbid_parking();
let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await;
let client_a = server.create_client(cx_a, "user_a").await;
let client_b = server.create_client(cx_b, "user_b").await;
let client_c = server.create_client(cx_c, "user_c").await;
let user_a = UserId::from_proto(client_a.user_id().unwrap());
server
.make_contacts(vec![
(&client_a, cx_a),
(&client_b, cx_b),
(&client_c, cx_c),
])
.await;
// Set up observers of the project and user stores. Any time either of
// these models update, they should be in a consistent state with each
// other. There should not be an observable moment where the current
// user's contact entry contains a project that does not match one of
// the current open projects. That would cause a duplicate entry to be
// shown in the contacts panel.
let mut subscriptions = vec![];
let (window_id, view) = cx_a.add_window(|cx| {
subscriptions.push(cx.observe(&client_a.user_store, {
let project_store = client_a.project_store.clone();
let user_store = client_a.user_store.clone();
move |_, _, cx| check_project_list(project_store.clone(), user_store.clone(), cx)
}));
subscriptions.push(cx.observe(&client_a.project_store, {
let project_store = client_a.project_store.clone();
let user_store = client_a.user_store.clone();
move |_, _, cx| check_project_list(project_store.clone(), user_store.clone(), cx)
}));
fn check_project_list(
project_store: ModelHandle<ProjectStore>,
user_store: ModelHandle<UserStore>,
cx: &mut gpui::MutableAppContext,
) {
let user_store = user_store.read(cx);
for contact in user_store.contacts() {
if contact.user.id == user_store.current_user().unwrap().id {
for project in &contact.projects {
let store_contains_project = project_store
.read(cx)
.projects(cx)
.filter_map(|project| project.read(cx).remote_id())
.any(|x| x == project.id);
if !store_contains_project {
panic!(
concat!(
"current user's contact data has a project",
"that doesn't match any open project {:?}",
),
project
);
}
}
}
}
}
EmptyView
});
// Build an offline project with two worktrees.
client_a
.fs
.insert_tree(
"/code",
json!({
"crate1": { "a.rs": "" },
"crate2": { "b.rs": "" },
}),
)
.await;
let project = cx_a.update(|cx| {
Project::local(
false,
client_a.client.clone(),
client_a.user_store.clone(),
client_a.project_store.clone(),
client_a.language_registry.clone(),
client_a.fs.clone(),
cx,
)
});
project
.update(cx_a, |p, cx| {
p.find_or_create_local_worktree("/code/crate1", true, cx)
})
.await
.unwrap();
project
.update(cx_a, |p, cx| {
p.find_or_create_local_worktree("/code/crate2", true, cx)
})
.await
.unwrap();
project
.update(cx_a, |p, cx| p.restore_state(cx))
.await
.unwrap();
// When a project is offline, we still create it on the server but is invisible
// to other users.
deterministic.run_until_parked();
assert!(server
.store
.lock()
.await
.project_metadata_for_user(user_a)
.is_empty());
project.read_with(cx_a, |project, _| {
assert!(project.remote_id().is_some());
assert!(!project.is_online());
});
assert!(client_b
.user_store
.read_with(cx_b, |store, _| { store.contacts()[0].projects.is_empty() }));
// When the project is taken online, its metadata is sent to the server
// and broadcasted to other users.
project.update(cx_a, |p, cx| p.set_online(true, cx));
deterministic.run_until_parked();
let project_id = project.read_with(cx_a, |p, _| p.remote_id()).unwrap();
client_b.user_store.read_with(cx_b, |store, _| {
assert_eq!(
store.contacts()[0].projects,
&[ProjectMetadata {
id: project_id,
visible_worktree_root_names: vec!["crate1".into(), "crate2".into()],
guests: Default::default(),
}]
);
});
// The project is registered again when the host loses and regains connection.
server.disconnect_client(user_a);
server.forbid_connections();
cx_a.foreground().advance_clock(rpc::RECEIVE_TIMEOUT);
assert!(server
.store
.lock()
.await
.project_metadata_for_user(user_a)
.is_empty());
assert!(project.read_with(cx_a, |p, _| p.remote_id().is_none()));
assert!(client_b
.user_store
.read_with(cx_b, |store, _| { store.contacts()[0].projects.is_empty() }));
server.allow_connections();
cx_b.foreground().advance_clock(Duration::from_secs(10));
let project_id = project.read_with(cx_a, |p, _| p.remote_id()).unwrap();
client_b.user_store.read_with(cx_b, |store, _| {
assert_eq!(
store.contacts()[0].projects,
&[ProjectMetadata {
id: project_id,
visible_worktree_root_names: vec!["crate1".into(), "crate2".into()],
guests: Default::default(),
}]
);
});
project
.update(cx_a, |p, cx| {
p.find_or_create_local_worktree("/code/crate3", true, cx)
})
.await
.unwrap();
deterministic.run_until_parked();
client_b.user_store.read_with(cx_b, |store, _| {
assert_eq!(
store.contacts()[0].projects,
&[ProjectMetadata {
id: project_id,
visible_worktree_root_names: vec![
"crate1".into(),
"crate2".into(),
"crate3".into()
],
guests: Default::default(),
}]
);
});
// Build another project using a directory which was previously part of
// an online project. Restore the project's state from the host's database.
let project2_a = cx_a.update(|cx| {
Project::local(
false,
client_a.client.clone(),
client_a.user_store.clone(),
client_a.project_store.clone(),
client_a.language_registry.clone(),
client_a.fs.clone(),
cx,
)
});
project2_a
.update(cx_a, |p, cx| {
p.find_or_create_local_worktree("/code/crate3", true, cx)
})
.await
.unwrap();
project2_a
.update(cx_a, |project, cx| project.restore_state(cx))
.await
.unwrap();
// This project is now online, because its directory was previously online.
project2_a.read_with(cx_a, |project, _| assert!(project.is_online()));
deterministic.run_until_parked();
let project2_id = project2_a.read_with(cx_a, |p, _| p.remote_id()).unwrap();
client_b.user_store.read_with(cx_b, |store, _| {
assert_eq!(
store.contacts()[0].projects,
&[
ProjectMetadata {
id: project_id,
visible_worktree_root_names: vec![
"crate1".into(),
"crate2".into(),
"crate3".into()
],
guests: Default::default(),
},
ProjectMetadata {
id: project2_id,
visible_worktree_root_names: vec!["crate3".into()],
guests: Default::default(),
}
]
);
});
let project2_b = client_b.build_remote_project(&project2_a, cx_a, cx_b).await;
let project2_c = cx_c.foreground().spawn(Project::remote(
project2_id,
client_c.client.clone(),
client_c.user_store.clone(),
client_c.project_store.clone(),
client_c.language_registry.clone(),
FakeFs::new(cx_c.background()),
cx_c.to_async(),
));
deterministic.run_until_parked();
// Taking a project offline unshares the project, rejects any pending join request and
// disconnects existing guests.
project2_a.update(cx_a, |project, cx| project.set_online(false, cx));
deterministic.run_until_parked();
project2_a.read_with(cx_a, |project, _| assert!(!project.is_shared()));
project2_b.read_with(cx_b, |project, _| assert!(project.is_read_only()));
project2_c.await.unwrap_err();
client_b.user_store.read_with(cx_b, |store, _| {
assert_eq!(
store.contacts()[0].projects,
&[ProjectMetadata {
id: project_id,
visible_worktree_root_names: vec![
"crate1".into(),
"crate2".into(),
"crate3".into()
],
guests: Default::default(),
},]
);
});
cx_a.update(|cx| {
drop(subscriptions);
drop(view);
cx.remove_window(window_id);
});
}
#[gpui::test(iterations = 10)]
async fn test_propagate_saves_and_fs_changes(
cx_a: &mut TestAppContext,
@ -3911,24 +3623,15 @@ async fn test_contacts(
deterministic.run_until_parked();
assert_eq!(
contacts(&client_a, cx_a),
[
("user_b".to_string(), true, vec![]),
("user_c".to_string(), true, vec![])
]
[("user_b".to_string(), true), ("user_c".to_string(), true)]
);
assert_eq!(
contacts(&client_b, cx_b),
[
("user_a".to_string(), true, vec![]),
("user_c".to_string(), true, vec![])
]
[("user_a".to_string(), true), ("user_c".to_string(), true)]
);
assert_eq!(
contacts(&client_c, cx_c),
[
("user_a".to_string(), true, vec![]),
("user_b".to_string(), true, vec![])
]
[("user_a".to_string(), true), ("user_b".to_string(), true)]
);
// Share a project as client A.
@ -3938,24 +3641,15 @@ async fn test_contacts(
deterministic.run_until_parked();
assert_eq!(
contacts(&client_a, cx_a),
[
("user_b".to_string(), true, vec![]),
("user_c".to_string(), true, vec![])
]
[("user_b".to_string(), true), ("user_c".to_string(), true)]
);
assert_eq!(
contacts(&client_b, cx_b),
[
("user_a".to_string(), true, vec![("a".to_string(), vec![])]),
("user_c".to_string(), true, vec![])
]
[("user_a".to_string(), true), ("user_c".to_string(), true)]
);
assert_eq!(
contacts(&client_c, cx_c),
[
("user_a".to_string(), true, vec![("a".to_string(), vec![])]),
("user_b".to_string(), true, vec![])
]
[("user_a".to_string(), true), ("user_b".to_string(), true)]
);
let _project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await;
@ -3963,32 +3657,15 @@ async fn test_contacts(
deterministic.run_until_parked();
assert_eq!(
contacts(&client_a, cx_a),
[
("user_b".to_string(), true, vec![]),
("user_c".to_string(), true, vec![])
]
[("user_b".to_string(), true), ("user_c".to_string(), true)]
);
assert_eq!(
contacts(&client_b, cx_b),
[
(
"user_a".to_string(),
true,
vec![("a".to_string(), vec!["user_b".to_string()])]
),
("user_c".to_string(), true, vec![])
]
[("user_a".to_string(), true,), ("user_c".to_string(), true)]
);
assert_eq!(
contacts(&client_c, cx_c),
[
(
"user_a".to_string(),
true,
vec![("a".to_string(), vec!["user_b".to_string()])]
),
("user_b".to_string(), true, vec![])
]
[("user_a".to_string(), true,), ("user_b".to_string(), true)]
);
// Add a local project as client B
@ -3998,32 +3675,15 @@ async fn test_contacts(
deterministic.run_until_parked();
assert_eq!(
contacts(&client_a, cx_a),
[
("user_b".to_string(), true, vec![("b".to_string(), vec![])]),
("user_c".to_string(), true, vec![])
]
[("user_b".to_string(), true), ("user_c".to_string(), true)]
);
assert_eq!(
contacts(&client_b, cx_b),
[
(
"user_a".to_string(),
true,
vec![("a".to_string(), vec!["user_b".to_string()])]
),
("user_c".to_string(), true, vec![])
]
[("user_a".to_string(), true), ("user_c".to_string(), true)]
);
assert_eq!(
contacts(&client_c, cx_c),
[
(
"user_a".to_string(),
true,
vec![("a".to_string(), vec!["user_b".to_string()])]
),
("user_b".to_string(), true, vec![("b".to_string(), vec![])])
]
[("user_a".to_string(), true,), ("user_b".to_string(), true)]
);
project_a
@ -4036,24 +3696,15 @@ async fn test_contacts(
deterministic.run_until_parked();
assert_eq!(
contacts(&client_a, cx_a),
[
("user_b".to_string(), true, vec![("b".to_string(), vec![])]),
("user_c".to_string(), true, vec![])
]
[("user_b".to_string(), true), ("user_c".to_string(), true)]
);
assert_eq!(
contacts(&client_b, cx_b),
[
("user_a".to_string(), true, vec![]),
("user_c".to_string(), true, vec![])
]
[("user_a".to_string(), true), ("user_c".to_string(), true)]
);
assert_eq!(
contacts(&client_c, cx_c),
[
("user_a".to_string(), true, vec![]),
("user_b".to_string(), true, vec![("b".to_string(), vec![])])
]
[("user_a".to_string(), true), ("user_b".to_string(), true)]
);
server.disconnect_client(client_c.current_user_id(cx_c));
@ -4061,17 +3712,11 @@ async fn test_contacts(
deterministic.advance_clock(rpc::RECEIVE_TIMEOUT);
assert_eq!(
contacts(&client_a, cx_a),
[
("user_b".to_string(), true, vec![("b".to_string(), vec![])]),
("user_c".to_string(), false, vec![])
]
[("user_b".to_string(), true), ("user_c".to_string(), false)]
);
assert_eq!(
contacts(&client_b, cx_b),
[
("user_a".to_string(), true, vec![]),
("user_c".to_string(), false, vec![])
]
[("user_a".to_string(), true), ("user_c".to_string(), false)]
);
assert_eq!(contacts(&client_c, cx_c), []);
@ -4084,48 +3729,24 @@ async fn test_contacts(
deterministic.run_until_parked();
assert_eq!(
contacts(&client_a, cx_a),
[
("user_b".to_string(), true, vec![("b".to_string(), vec![])]),
("user_c".to_string(), true, vec![])
]
[("user_b".to_string(), true), ("user_c".to_string(), true)]
);
assert_eq!(
contacts(&client_b, cx_b),
[
("user_a".to_string(), true, vec![]),
("user_c".to_string(), true, vec![])
]
[("user_a".to_string(), true), ("user_c".to_string(), true)]
);
assert_eq!(
contacts(&client_c, cx_c),
[
("user_a".to_string(), true, vec![]),
("user_b".to_string(), true, vec![("b".to_string(), vec![])])
]
[("user_a".to_string(), true), ("user_b".to_string(), true)]
);
#[allow(clippy::type_complexity)]
fn contacts(
client: &TestClient,
cx: &TestAppContext,
) -> Vec<(String, bool, Vec<(String, Vec<String>)>)> {
fn contacts(client: &TestClient, cx: &TestAppContext) -> Vec<(String, bool)> {
client.user_store.read_with(cx, |store, _| {
store
.contacts()
.iter()
.map(|contact| {
let projects = contact
.projects
.iter()
.map(|p| {
(
p.visible_worktree_root_names[0].clone(),
p.guests.iter().map(|p| p.github_login.clone()).collect(),
)
})
.collect();
(contact.user.github_login.clone(), contact.online, projects)
})
.map(|contact| (contact.user.github_login.clone(), contact.online))
.collect()
})
}
@ -5155,22 +4776,6 @@ async fn test_random_collaboration(
log::error!("{} error - {:?}", guest.username, guest_err);
}
let contacts = server
.app_state
.db
.get_contacts(guest.current_user_id(&guest_cx))
.await
.unwrap();
let contacts = server
.store
.lock()
.await
.build_initial_contacts_update(contacts)
.contacts;
assert!(!contacts
.iter()
.flat_map(|contact| &contact.projects)
.any(|project| project.id == host_project_id));
guest_project.read_with(&guest_cx, |project, _| assert!(project.is_read_only()));
guest_cx.update(|_| drop((guest, guest_project)));
}
@ -5259,14 +4864,6 @@ async fn test_random_collaboration(
"removed guest is still a contact of another peer"
);
}
for project in contact.projects {
for project_guest_id in project.guests {
assert_ne!(
project_guest_id, removed_guest_id.0 as u64,
"removed guest appears as still participating on a project"
);
}
}
}
}

View File

@ -345,47 +345,11 @@ impl Store {
pub fn contact_for_user(&self, user_id: UserId, should_notify: bool) -> proto::Contact {
proto::Contact {
user_id: user_id.to_proto(),
projects: self.project_metadata_for_user(user_id),
online: self.is_user_online(user_id),
should_notify,
}
}
pub fn project_metadata_for_user(&self, user_id: UserId) -> Vec<proto::ProjectMetadata> {
let user_connection_state = self.connected_users.get(&user_id);
let project_ids = user_connection_state.iter().flat_map(|state| {
state
.connection_ids
.iter()
.filter_map(|connection_id| self.connections.get(connection_id))
.flat_map(|connection| connection.projects.iter().copied())
});
let mut metadata = Vec::new();
for project_id in project_ids {
if let Some(project) = self.projects.get(&project_id) {
if project.host.user_id == user_id && project.online {
metadata.push(proto::ProjectMetadata {
id: project_id.to_proto(),
visible_worktree_root_names: project
.worktrees
.values()
.filter(|worktree| worktree.visible)
.map(|worktree| worktree.root_name.clone())
.collect(),
guests: project
.guests
.values()
.map(|guest| guest.user_id.to_proto())
.collect(),
});
}
}
}
metadata
}
pub fn create_room(&mut self, creator_connection_id: ConnectionId) -> Result<RoomId> {
let connection = self
.connections

View File

@ -8,23 +8,19 @@ use contact_notification::ContactNotification;
use editor::{Cancel, Editor};
use fuzzy::{match_strings, StringMatchCandidate};
use gpui::{
actions,
elements::*,
geometry::{rect::RectF, vector::vec2f},
impl_actions, impl_internal_actions,
platform::CursorStyle,
actions, elements::*, impl_actions, impl_internal_actions, platform::CursorStyle,
AnyViewHandle, AppContext, ClipboardItem, Element, ElementBox, Entity, ModelHandle,
MouseButton, MutableAppContext, RenderContext, Subscription, View, ViewContext, ViewHandle,
WeakModelHandle, WeakViewHandle,
WeakViewHandle,
};
use join_project_notification::JoinProjectNotification;
use menu::{Confirm, SelectNext, SelectPrev};
use project::{Project, ProjectStore};
use project::ProjectStore;
use serde::Deserialize;
use settings::Settings;
use std::{ops::DerefMut, sync::Arc};
use std::sync::Arc;
use theme::IconButton;
use workspace::{sidebar::SidebarItem, JoinProject, ToggleProjectOnline, Workspace};
use workspace::{sidebar::SidebarItem, Workspace};
actions!(contacts_panel, [ToggleFocus]);
@ -48,8 +44,6 @@ enum ContactEntry {
IncomingRequest(Arc<User>),
OutgoingRequest(Arc<User>),
Contact(Arc<Contact>),
ContactProject(Arc<Contact>, usize, Option<WeakModelHandle<Project>>),
OfflineProject(WeakModelHandle<Project>),
}
#[derive(Clone, PartialEq)]
@ -181,7 +175,6 @@ impl ContactsPanel {
let list_state = ListState::new(0, Orientation::Top, 1000., cx, move |this, ix, cx| {
let theme = cx.global::<Settings>().theme.clone();
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] {
@ -214,34 +207,6 @@ impl ContactsPanel {
ContactEntry::Contact(contact) => {
Self::render_contact(&contact.user, &theme.contacts_panel, is_selected)
}
ContactEntry::ContactProject(contact, project_ix, open_project) => {
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_project(
contact.clone(),
current_user_id,
*project_ix,
*open_project,
&theme.contacts_panel,
&theme.tooltip,
is_last_project_for_contact,
is_selected,
cx,
)
}
ContactEntry::OfflineProject(project) => Self::render_offline_project(
*project,
&theme.contacts_panel,
&theme.tooltip,
is_selected,
cx,
),
}
});
@ -343,260 +308,6 @@ impl ContactsPanel {
.boxed()
}
#[allow(clippy::too_many_arguments)]
fn render_project(
contact: Arc<Contact>,
current_user_id: Option<u64>,
project_index: usize,
open_project: Option<WeakModelHandle<Project>>,
theme: &theme::ContactsPanel,
tooltip_style: &TooltipStyle,
is_last_project: bool,
is_selected: bool,
cx: &mut RenderContext<Self>,
) -> ElementBox {
enum ToggleOnline {}
let project = &contact.projects[project_index];
let project_id = project.id;
let is_host = Some(contact.user.id) == current_user_id;
let open_project = open_project.and_then(|p| p.upgrade(cx.deref_mut()));
let font_cache = cx.font_cache();
let host_avatar_height = theme
.contact_avatar
.width
.or(theme.contact_avatar.height)
.unwrap_or(0.);
let row = &theme.project_row.default;
let tree_branch = theme.tree_branch;
let line_height = row.name.text.line_height(font_cache);
let cap_height = row.name.text.cap_height(font_cache);
let baseline_offset =
row.name.text.baseline_offset(font_cache) + (theme.row_height - line_height) / 2.;
MouseEventHandler::<JoinProject>::new(project_id as usize, cx, |mouse_state, cx| {
let tree_branch = *tree_branch.style_for(mouse_state, is_selected);
let row = theme.project_row.style_for(mouse_state, is_selected);
Flex::row()
.with_child(
Stack::new()
.with_child(
Canvas::new(move |bounds, _, cx| {
let start_x = bounds.min_x() + (bounds.width() / 2.)
- (tree_branch.width / 2.);
let end_x = bounds.max_x();
let start_y = bounds.min_y();
let end_y = bounds.min_y() + baseline_offset - (cap_height / 2.);
cx.scene.push_quad(gpui::Quad {
bounds: RectF::from_points(
vec2f(start_x, start_y),
vec2f(
start_x + tree_branch.width,
if is_last_project {
end_y
} else {
bounds.max_y()
},
),
),
background: Some(tree_branch.color),
border: gpui::Border::default(),
corner_radius: 0.,
});
cx.scene.push_quad(gpui::Quad {
bounds: RectF::from_points(
vec2f(start_x, end_y),
vec2f(end_x, end_y + tree_branch.width),
),
background: Some(tree_branch.color),
border: gpui::Border::default(),
corner_radius: 0.,
});
})
.boxed(),
)
.with_children(open_project.and_then(|open_project| {
let is_going_offline = !open_project.read(cx).is_online();
if !mouse_state.hovered && !is_going_offline {
return None;
}
let button = MouseEventHandler::<ToggleProjectOnline>::new(
project_id as usize,
cx,
|state, _| {
let mut icon_style =
*theme.private_button.style_for(state, false);
icon_style.container.background_color =
row.container.background_color;
if is_going_offline {
icon_style.color = theme.disabled_button.color;
}
render_icon_button(&icon_style, "icons/lock_8.svg")
.aligned()
.boxed()
},
);
if is_going_offline {
Some(button.boxed())
} else {
Some(
button
.with_cursor_style(CursorStyle::PointingHand)
.on_click(MouseButton::Left, move |_, cx| {
cx.dispatch_action(ToggleProjectOnline {
project: Some(open_project.clone()),
})
})
.with_tooltip::<ToggleOnline, _>(
project_id as usize,
"Take project offline".to_string(),
None,
tooltip_style.clone(),
cx,
)
.boxed(),
)
}
}))
.constrained()
.with_width(host_avatar_height)
.boxed(),
)
.with_child(
Label::new(
project.visible_worktree_root_names.join(", "),
row.name.text.clone(),
)
.aligned()
.left()
.contained()
.with_style(row.name.container)
.flex(1., false)
.boxed(),
)
.with_children(project.guests.iter().filter_map(|participant| {
participant.avatar.clone().map(|avatar| {
Image::new(avatar)
.with_style(row.guest_avatar)
.aligned()
.left()
.contained()
.with_margin_right(row.guest_avatar_spacing)
.boxed()
})
}))
.constrained()
.with_height(theme.row_height)
.contained()
.with_style(row.container)
.boxed()
})
.with_cursor_style(if !is_host {
CursorStyle::PointingHand
} else {
CursorStyle::Arrow
})
.on_click(MouseButton::Left, move |_, cx| {
if !is_host {
cx.dispatch_global_action(JoinProject {
contact: contact.clone(),
project_index,
});
}
})
.boxed()
}
fn render_offline_project(
project_handle: WeakModelHandle<Project>,
theme: &theme::ContactsPanel,
tooltip_style: &TooltipStyle,
is_selected: bool,
cx: &mut RenderContext<Self>,
) -> ElementBox {
let host_avatar_height = theme
.contact_avatar
.width
.or(theme.contact_avatar.height)
.unwrap_or(0.);
enum LocalProject {}
enum ToggleOnline {}
let project_id = project_handle.id();
MouseEventHandler::<LocalProject>::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 = if let Some(project) = project_handle.upgrade(cx.deref_mut()) {
project.read(cx)
} else {
return Empty::new().boxed();
};
let is_going_online = project.is_online();
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({
let button =
MouseEventHandler::<ToggleOnline>::new(project_id, cx, |state, _| {
let mut style = *theme.private_button.style_for(state, false);
if is_going_online {
style.color = theme.disabled_button.color;
}
render_icon_button(&style, "icons/lock_8.svg")
.aligned()
.constrained()
.with_width(host_avatar_height)
.boxed()
});
if is_going_online {
button.boxed()
} else {
button
.with_cursor_style(CursorStyle::PointingHand)
.on_click(MouseButton::Left, move |_, cx| {
let project = project_handle.upgrade(cx.app);
cx.dispatch_action(ToggleProjectOnline { project })
})
.with_tooltip::<ToggleOnline, _>(
project_id,
"Take project online".to_string(),
None,
tooltip_style.clone(),
cx,
)
.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>,
@ -710,7 +421,6 @@ 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();
@ -837,60 +547,6 @@ impl ContactsPanel {
for mat in matches {
let contact = &contacts[mat.candidate_id];
self.entries.push(ContactEntry::Contact(contact.clone()));
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.visible_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::OfflineProject(project.downgrade()))
}
},
));
} else {
self.entries.extend(
contact.projects.iter().enumerate().filter_map(
|(ix, project)| {
if project.visible_worktree_root_names.is_empty() {
None
} else {
Some(ContactEntry::ContactProject(
contact.clone(),
ix,
None,
))
}
},
),
);
}
}
}
}
@ -981,18 +637,6 @@ impl ContactsPanel {
let section = *section;
self.toggle_expanded(&ToggleExpanded(section), cx);
}
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,
})
}
}
_ => {}
}
}
@ -1181,16 +825,6 @@ 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 {
return contact_1.user.id == contact_2.user.id && ix_1 == ix_2;
}
}
ContactEntry::OfflineProject(project_1) => {
if let ContactEntry::OfflineProject(project_2) = other {
return project_1.id() == project_2.id();
}
}
}
false
}
@ -1205,7 +839,7 @@ mod tests {
Client,
};
use collections::HashSet;
use gpui::{serde_json::json, TestAppContext};
use gpui::TestAppContext;
use language::LanguageRegistry;
use project::{FakeFs, Project};
@ -1221,8 +855,6 @@ mod tests {
let project_store = cx.add_model(|_| ProjectStore::new(project::Db::open_fake()));
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,
@ -1234,14 +866,6 @@ mod tests {
cx,
)
});
let worktree_id = project
.update(cx, |project, cx| {
project.find_or_create_local_worktree("/private_dir", true, cx)
})
.await
.unwrap()
.0
.read_with(cx, |worktree, _| worktree.id().to_proto());
let (_, workspace) =
cx.add_window(|cx| Workspace::new(project.clone(), |_, _| unimplemented!(), cx));
@ -1315,55 +939,26 @@ mod tests {
user_id: 3,
online: true,
should_notify: false,
projects: vec![proto::ProjectMetadata {
id: 101,
visible_worktree_root_names: vec!["dir1".to_string()],
guests: vec![2],
}],
},
proto::Contact {
user_id: 4,
online: true,
should_notify: false,
projects: vec![proto::ProjectMetadata {
id: 102,
visible_worktree_root_names: vec!["dir2".to_string()],
guests: vec![2],
}],
},
proto::Contact {
user_id: 5,
online: false,
should_notify: false,
projects: vec![],
},
proto::Contact {
user_id: current_user_id,
online: true,
should_notify: false,
projects: vec![proto::ProjectMetadata {
id: 103,
visible_worktree_root_names: vec!["dir3".to_string()],
guests: vec![3],
}],
},
],
..Default::default()
});
assert_eq!(
server
.receive::<proto::UpdateProject>()
.await
.unwrap()
.payload,
proto::UpdateProject {
project_id: 200,
online: false,
worktrees: vec![]
},
);
cx.foreground().run_until_parked();
assert_eq!(
cx.read(|cx| render_to_strings(&panel, cx)),
@ -1373,168 +968,8 @@ mod tests {
" outgoing user_two",
"v Online",
" the_current_user",
" dir3",
" 🔒 private_dir",
" user_four",
" dir2",
" user_three",
" dir1",
"v Offline",
" user_five",
]
);
// Take a project online. It appears as loading, since the project
// isn't yet visible to other contacts.
project.update(cx, |project, cx| project.set_online(true, cx));
cx.foreground().run_until_parked();
assert_eq!(
cx.read(|cx| render_to_strings(&panel, cx)),
&[
"v Requests",
" incoming user_one",
" outgoing user_two",
"v Online",
" the_current_user",
" dir3",
" 🔒 private_dir (going online...)",
" user_four",
" dir2",
" user_three",
" dir1",
"v Offline",
" user_five",
]
);
// The server receives the project's metadata and updates the contact metadata
// for the current user. Now the project appears as online.
assert_eq!(
server
.receive::<proto::UpdateProject>()
.await
.unwrap()
.payload,
proto::UpdateProject {
project_id: 200,
online: true,
worktrees: vec![proto::WorktreeMetadata {
id: worktree_id,
root_name: "private_dir".to_string(),
visible: true,
}]
},
);
server
.receive::<proto::UpdateWorktreeExtensions>()
.await
.unwrap();
server.send(proto::UpdateContacts {
contacts: vec![proto::Contact {
user_id: current_user_id,
online: true,
should_notify: false,
projects: vec![
proto::ProjectMetadata {
id: 103,
visible_worktree_root_names: vec!["dir3".to_string()],
guests: vec![3],
},
proto::ProjectMetadata {
id: 200,
visible_worktree_root_names: vec!["private_dir".to_string()],
guests: vec![3],
},
],
}],
..Default::default()
});
cx.foreground().run_until_parked();
assert_eq!(
cx.read(|cx| render_to_strings(&panel, cx)),
&[
"v Requests",
" incoming user_one",
" outgoing user_two",
"v Online",
" the_current_user",
" dir3",
" private_dir",
" user_four",
" dir2",
" user_three",
" dir1",
"v Offline",
" user_five",
]
);
// Take the project offline. It appears as loading.
project.update(cx, |project, cx| project.set_online(false, cx));
cx.foreground().run_until_parked();
assert_eq!(
cx.read(|cx| render_to_strings(&panel, cx)),
&[
"v Requests",
" incoming user_one",
" outgoing user_two",
"v Online",
" the_current_user",
" dir3",
" private_dir (going offline...)",
" user_four",
" dir2",
" user_three",
" dir1",
"v Offline",
" user_five",
]
);
// The server receives the unregister request and updates the contact
// metadata for the current user. The project is now offline.
assert_eq!(
server
.receive::<proto::UpdateProject>()
.await
.unwrap()
.payload,
proto::UpdateProject {
project_id: 200,
online: false,
worktrees: vec![]
},
);
server.send(proto::UpdateContacts {
contacts: vec![proto::Contact {
user_id: current_user_id,
online: true,
should_notify: false,
projects: vec![proto::ProjectMetadata {
id: 103,
visible_worktree_root_names: vec!["dir3".to_string()],
guests: vec![3],
}],
}],
..Default::default()
});
cx.foreground().run_until_parked();
assert_eq!(
cx.read(|cx| render_to_strings(&panel, cx)),
&[
"v Requests",
" incoming user_one",
" outgoing user_two",
"v Online",
" the_current_user",
" dir3",
" 🔒 private_dir",
" user_four",
" dir2",
" user_three",
" dir1",
"v Offline",
" user_five",
]
@ -1551,7 +986,6 @@ mod tests {
&[
"v Online",
" user_four <=== selected",
" dir2",
"v Offline",
" user_five",
]
@ -1565,25 +999,23 @@ mod tests {
&[
"v Online",
" user_four",
" dir2 <=== selected",
"v Offline",
" user_five",
]
);
panel.update(cx, |panel, cx| {
panel.select_next(&Default::default(), cx);
});
assert_eq!(
cx.read(|cx| render_to_strings(&panel, cx)),
&[
"v Online",
" user_four",
" dir2",
"v Offline <=== selected",
" user_five",
]
);
panel.update(cx, |panel, cx| {
panel.select_next(&Default::default(), cx);
});
assert_eq!(
cx.read(|cx| render_to_strings(&panel, cx)),
&[
"v Online",
" user_four",
"v Offline",
" user_five <=== selected",
]
);
}
fn render_to_strings(panel: &ViewHandle<ContactsPanel>, cx: &AppContext) -> Vec<String> {
@ -1608,37 +1040,6 @@ mod tests {
ContactEntry::Contact(contact) => {
format!(" {}", contact.user.github_login)
}
ContactEntry::ContactProject(contact, project_ix, project) => {
let project = project
.and_then(|p| p.upgrade(cx))
.map(|project| project.read(cx));
format!(
" {}{}",
contact.projects[*project_ix]
.visible_worktree_root_names
.join(", "),
if project.map_or(true, |project| project.is_online()) {
""
} else {
" (going offline...)"
},
)
}
ContactEntry::OfflineProject(project) => {
let project = project.upgrade(cx).unwrap().read(cx);
format!(
" 🔒 {}{}",
project
.worktree_root_names(cx)
.collect::<Vec<_>>()
.join(", "),
if project.is_online() {
" (going online...)"
} else {
""
},
)
}
};
if panel.selection == Some(ix) {

View File

@ -1048,15 +1048,8 @@ message ChannelMessage {
message Contact {
uint64 user_id = 1;
repeated ProjectMetadata projects = 2;
bool online = 3;
bool should_notify = 4;
}
message ProjectMetadata {
uint64 id = 1;
repeated string visible_worktree_root_names = 3;
repeated uint64 guests = 4;
bool online = 2;
bool should_notify = 3;
}
message WorktreeMetadata {

View File

@ -1,185 +0,0 @@
use crate::{sidebar::SidebarSide, AppState, ToggleFollow, Workspace};
use anyhow::Result;
use client::{proto, Client, Contact};
use gpui::{
elements::*, ElementBox, Entity, ImageData, MutableAppContext, RenderContext, Task, View,
ViewContext,
};
use project::Project;
use settings::Settings;
use std::sync::Arc;
use util::ResultExt;
pub struct WaitingRoom {
project_id: u64,
avatar: Option<Arc<ImageData>>,
message: String,
waiting: bool,
client: Arc<Client>,
_join_task: Task<Result<()>>,
}
impl Entity for WaitingRoom {
type Event = ();
fn release(&mut self, _: &mut MutableAppContext) {
if self.waiting {
self.client
.send(proto::LeaveProject {
project_id: self.project_id,
})
.log_err();
}
}
}
impl View for WaitingRoom {
fn ui_name() -> &'static str {
"WaitingRoom"
}
fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
let theme = &cx.global::<Settings>().theme.workspace;
Flex::column()
.with_children(self.avatar.clone().map(|avatar| {
Image::new(avatar)
.with_style(theme.joining_project_avatar)
.aligned()
.boxed()
}))
.with_child(
Text::new(
self.message.clone(),
theme.joining_project_message.text.clone(),
)
.contained()
.with_style(theme.joining_project_message.container)
.aligned()
.boxed(),
)
.aligned()
.contained()
.with_background_color(theme.background)
.boxed()
}
}
impl WaitingRoom {
pub fn new(
contact: Arc<Contact>,
project_index: usize,
app_state: Arc<AppState>,
cx: &mut ViewContext<Self>,
) -> Self {
let project_id = contact.projects[project_index].id;
let client = app_state.client.clone();
let _join_task = cx.spawn_weak({
let contact = contact.clone();
|this, mut cx| async move {
let project = Project::remote(
project_id,
app_state.client.clone(),
app_state.user_store.clone(),
app_state.project_store.clone(),
app_state.languages.clone(),
app_state.fs.clone(),
cx.clone(),
)
.await;
if let Some(this) = this.upgrade(&cx) {
this.update(&mut cx, |this, cx| {
this.waiting = false;
match project {
Ok(project) => {
cx.replace_root_view(|cx| {
let mut workspace =
Workspace::new(project, app_state.default_item_factory, cx);
(app_state.initialize_workspace)(
&mut workspace,
&app_state,
cx,
);
workspace.toggle_sidebar(SidebarSide::Left, cx);
if let Some((host_peer_id, _)) = workspace
.project
.read(cx)
.collaborators()
.iter()
.find(|(_, collaborator)| collaborator.replica_id == 0)
{
if let Some(follow) = workspace
.toggle_follow(&ToggleFollow(*host_peer_id), cx)
{
follow.detach_and_log_err(cx);
}
}
workspace
});
}
Err(error) => {
let login = &contact.user.github_login;
let message = match error {
project::JoinProjectError::HostDeclined => {
format!("@{} declined your request.", login)
}
project::JoinProjectError::HostClosedProject => {
format!(
"@{} closed their copy of {}.",
login,
humanize_list(
&contact.projects[project_index]
.visible_worktree_root_names
)
)
}
project::JoinProjectError::HostWentOffline => {
format!("@{} went offline.", login)
}
project::JoinProjectError::Other(error) => {
log::error!("error joining project: {}", error);
"An error occurred.".to_string()
}
};
this.message = message;
cx.notify();
}
}
})
}
Ok(())
}
});
Self {
project_id,
avatar: contact.user.avatar.clone(),
message: format!(
"Asking to join @{}'s copy of {}...",
contact.user.github_login,
humanize_list(&contact.projects[project_index].visible_worktree_root_names)
),
waiting: true,
client,
_join_task,
}
}
}
fn humanize_list<'a>(items: impl IntoIterator<Item = &'a String>) -> String {
let mut list = String::new();
let mut items = items.into_iter().enumerate().peekable();
while let Some((ix, item)) = items.next() {
if ix > 0 {
list.push_str(", ");
if items.peek().is_none() {
list.push_str("and ");
}
}
list.push_str(item);
}
list
}

View File

@ -10,7 +10,6 @@ pub mod searchable;
pub mod sidebar;
mod status_bar;
mod toolbar;
mod waiting_room;
use anyhow::{anyhow, Context, Result};
use client::{proto, Client, Contact, PeerId, Subscription, TypedEnvelope, UserStore};
@ -58,7 +57,6 @@ use std::{
use theme::{Theme, ThemeRegistry};
pub use toolbar::{ToolbarItemLocation, ToolbarItemView};
use util::ResultExt;
use waiting_room::WaitingRoom;
type ProjectItemBuilders = HashMap<
TypeId,
@ -167,14 +165,6 @@ pub fn init(app_state: Arc<AppState>, cx: &mut MutableAppContext) {
}
}
});
cx.add_global_action({
let app_state = Arc::downgrade(&app_state);
move |action: &JoinProject, cx: &mut MutableAppContext| {
if let Some(app_state) = app_state.upgrade() {
join_project(action.contact.clone(), action.project_index, &app_state, cx);
}
}
});
cx.add_async_action(Workspace::toggle_follow);
cx.add_async_action(Workspace::follow_next_collaborator);
@ -2663,28 +2653,6 @@ pub fn open_paths(
})
}
pub fn join_project(
contact: Arc<Contact>,
project_index: usize,
app_state: &Arc<AppState>,
cx: &mut MutableAppContext,
) {
let project_id = contact.projects[project_index].id;
for window_id in cx.window_ids().collect::<Vec<_>>() {
if let Some(workspace) = cx.root_view::<Workspace>(window_id) {
if workspace.read(cx).project().read(cx).remote_id() == Some(project_id) {
cx.activate_window(window_id);
return;
}
}
}
cx.add_window((app_state.build_window_options)(), |cx| {
WaitingRoom::new(contact, project_index, app_state.clone(), cx)
});
}
fn open_new(app_state: &Arc<AppState>, cx: &mut MutableAppContext) {
let (window_id, workspace) = cx.add_window((app_state.build_window_options)(), |cx| {
let mut workspace = Workspace::new(