diff --git a/Cargo.lock b/Cargo.lock index 98bcfe5a91..0d3271c066 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -934,6 +934,7 @@ dependencies = [ "futures", "fuzzy", "gpui", + "log", "postage", "serde", "settings", diff --git a/crates/client/src/user.rs b/crates/client/src/user.rs index ef38c6e2da..6fd9b1ded1 100644 --- a/crates/client/src/user.rs +++ b/crates/client/src/user.rs @@ -1,6 +1,6 @@ use super::{http::HttpClient, proto, Client, Status, TypedEnvelope}; use anyhow::{anyhow, Context, Result}; -use futures::{future, AsyncReadExt, Future}; +use futures::{future, AsyncReadExt}; use gpui::{AsyncAppContext, Entity, ImageData, ModelContext, ModelHandle, Task}; use postage::{prelude::Stream, sink::Sink, watch}; use rpc::proto::{RequestMessage, UsersResponse}; @@ -31,11 +31,12 @@ pub struct ProjectMetadata { pub guests: Vec>, } -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ContactRequestStatus { None, - SendingRequest, - Requested, + Pending, + RequestSent, + RequestReceived, RequestAccepted, } @@ -192,7 +193,6 @@ impl UserStore { Err(ix) => this.contacts.insert(ix, updated_contact), } } - cx.notify(); // Remove incoming contact requests this.incoming_contact_requests @@ -223,6 +223,8 @@ impl UserStore { Err(ix) => this.outgoing_contact_requests.insert(ix, request), } } + + cx.notify(); }); Ok(()) @@ -248,7 +250,9 @@ impl UserStore { } pub fn contact_request_status(&self, user: &User) -> ContactRequestStatus { - if self + if self.pending_contact_requests.contains_key(&user.id) { + ContactRequestStatus::Pending + } else if self .contacts .binary_search_by_key(&&user.id, |contact| &contact.user.id) .is_ok() @@ -259,9 +263,13 @@ impl UserStore { .binary_search_by_key(&&user.id, |user| &user.id) .is_ok() { - ContactRequestStatus::Requested - } else if self.pending_contact_requests.contains_key(&user.id) { - ContactRequestStatus::SendingRequest + ContactRequestStatus::RequestSent + } else if self + .incoming_contact_requests + .binary_search_by_key(&&user.id, |user| &user.id) + .is_ok() + { + ContactRequestStatus::RequestReceived } else { ContactRequestStatus::None } @@ -272,37 +280,42 @@ impl UserStore { responder_id: u64, cx: &mut ModelContext, ) -> Task> { - let client = self.client.upgrade(); - *self - .pending_contact_requests - .entry(responder_id) - .or_insert(0) += 1; - cx.notify(); - - cx.spawn(|this, mut cx| async move { - let request = client - .ok_or_else(|| anyhow!("can't upgrade client reference"))? - .request(proto::RequestContact { responder_id }); - request.await?; - this.update(&mut cx, |this, cx| { - if let Entry::Occupied(mut request_count) = - this.pending_contact_requests.entry(responder_id) - { - *request_count.get_mut() -= 1; - if *request_count.get() == 0 { - request_count.remove(); - } - } - cx.notify(); - }); - Ok(()) - }) + self.perform_contact_request(responder_id, proto::RequestContact { responder_id }, cx) } pub fn remove_contact( &mut self, user_id: u64, cx: &mut ModelContext, + ) -> Task> { + self.perform_contact_request(user_id, proto::RemoveContact { user_id }, cx) + } + + pub fn respond_to_contact_request( + &mut self, + requester_id: u64, + accept: bool, + cx: &mut ModelContext, + ) -> Task> { + self.perform_contact_request( + requester_id, + proto::RespondToContactRequest { + requester_id, + response: if accept { + proto::ContactRequestResponse::Accept + } else { + proto::ContactRequestResponse::Reject + } as i32, + }, + cx, + ) + } + + fn perform_contact_request( + &mut self, + user_id: u64, + request: T, + cx: &mut ModelContext, ) -> Task> { let client = self.client.upgrade(); *self.pending_contact_requests.entry(user_id).or_insert(0) += 1; @@ -311,7 +324,7 @@ impl UserStore { cx.spawn(|this, mut cx| async move { let request = client .ok_or_else(|| anyhow!("can't upgrade client reference"))? - .request(proto::RemoveContact { user_id }); + .request(request); request.await?; this.update(&mut cx, |this, cx| { if let Entry::Occupied(mut request_count) = @@ -328,28 +341,6 @@ impl UserStore { }) } - pub fn respond_to_contact_request( - &self, - requester_id: u64, - accept: bool, - ) -> impl Future> { - let client = self.client.upgrade(); - async move { - client - .ok_or_else(|| anyhow!("not logged in"))? - .request(proto::RespondToContactRequest { - requester_id, - response: if accept { - proto::ContactRequestResponse::Accept - } else { - proto::ContactRequestResponse::Reject - } as i32, - }) - .await?; - Ok(()) - } - } - #[cfg(any(test, feature = "test-support"))] pub fn clear_contacts(&mut self) { self.contacts.clear(); diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index 6dabc63eaa..4ccc332e8c 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -5237,8 +5237,8 @@ mod tests { // User B accepts the request from user A. client_b .user_store - .read_with(cx_b, |store, _| { - store.respond_to_contact_request(client_a.user_id().unwrap(), true) + .update(cx_b, |store, cx| { + store.respond_to_contact_request(client_a.user_id().unwrap(), true, cx) }) .await .unwrap(); @@ -5281,8 +5281,8 @@ mod tests { // User B rejects the request from user C. client_b .user_store - .read_with(cx_b, |store, _| { - store.respond_to_contact_request(client_c.user_id().unwrap(), false) + .update(cx_b, |store, cx| { + store.respond_to_contact_request(client_c.user_id().unwrap(), false, cx) }) .await .unwrap(); @@ -6506,8 +6506,8 @@ mod tests { cx_a.foreground().run_until_parked(); client_b .user_store - .update(*cx_b, |store, _| { - store.respond_to_contact_request(client_a.user_id().unwrap(), true) + .update(*cx_b, |store, cx| { + store.respond_to_contact_request(client_a.user_id().unwrap(), true, cx) }) .await .unwrap(); diff --git a/crates/contacts_panel/Cargo.toml b/crates/contacts_panel/Cargo.toml index e511b9d030..69cef3177f 100644 --- a/crates/contacts_panel/Cargo.toml +++ b/crates/contacts_panel/Cargo.toml @@ -17,5 +17,6 @@ theme = { path = "../theme" } util = { path = "../util" } workspace = { path = "../workspace" } futures = "0.3" +log = "0.4" postage = { version = "0.4.1", features = ["futures-traits"] } serde = { version = "1", features = ["derive"] } diff --git a/crates/contacts_panel/src/contacts_panel.rs b/crates/contacts_panel/src/contacts_panel.rs index eb64afb2d5..11883e8837 100644 --- a/crates/contacts_panel/src/contacts_panel.rs +++ b/crates/contacts_panel/src/contacts_panel.rs @@ -1,8 +1,7 @@ use client::{Contact, ContactRequestStatus, User, UserStore}; use editor::Editor; -use fuzzy::StringMatchCandidate; +use fuzzy::{match_strings, StringMatchCandidate}; use gpui::{ - anyhow, elements::*, geometry::{rect::RectF, vector::vec2f}, impl_actions, @@ -13,15 +12,28 @@ use gpui::{ use serde::Deserialize; use settings::Settings; use std::sync::Arc; -use util::ResultExt; +use util::TryFutureExt; use workspace::{AppState, JoinProject}; -impl_actions!(contacts_panel, [RequestContact, RemoveContact]); +impl_actions!( + contacts_panel, + [RequestContact, RemoveContact, RespondToContactRequest] +); + +#[derive(Debug)] +enum ContactEntry { + Header(&'static str), + IncomingRequest(Arc), + OutgoingRequest(Arc), + Contact(Arc), + PotentialContact(Arc), +} pub struct ContactsPanel { - list_state: ListState, - contacts: Vec>, + entries: Vec, + match_candidates: Vec, potential_contacts: Vec>, + list_state: ListState, user_store: ModelHandle, contacts_search_task: Option>>, user_query_editor: ViewHandle, @@ -34,9 +46,16 @@ pub struct RequestContact(pub u64); #[derive(Clone, Deserialize)] pub struct RemoveContact(pub u64); +#[derive(Clone, Deserialize)] +pub struct RespondToContactRequest { + pub user_id: u64, + pub accept: bool, +} + pub fn init(cx: &mut MutableAppContext) { cx.add_action(ContactsPanel::request_contact); cx.add_action(ContactsPanel::remove_contact); + cx.add_action(ContactsPanel::respond_to_contact_request); } impl ContactsPanel { @@ -50,29 +69,26 @@ impl ContactsPanel { cx.subscribe(&user_query_editor, |this, _, event, cx| { if let editor::Event::BufferEdited = event { - this.filter_contacts(true, cx) + this.query_changed(cx) } }) .detach(); - Self { - list_state: ListState::new( - 1 + app_state.user_store.read(cx).contacts().len(), // Add 1 for the "Contacts" header - Orientation::Top, - 1000., - { - let this = cx.weak_handle(); - let app_state = app_state.clone(); - move |ix, cx| { - let this = this.upgrade(cx).unwrap(); - let this = this.read(cx); - let current_user_id = - this.user_store.read(cx).current_user().map(|user| user.id); - let theme = cx.global::().theme.clone(); - let theme = &theme.contacts_panel; + let mut this = Self { + list_state: ListState::new(0, Orientation::Top, 1000., { + let this = cx.weak_handle(); + let app_state = app_state.clone(); + move |ix, cx| { + let this = this.upgrade(cx).unwrap(); + let this = this.read(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); - if ix == 0 { - Label::new("contacts".to_string(), theme.header.text.clone()) + match &this.entries[ix] { + ContactEntry::Header(text) => { + Label::new(text.to_string(), theme.header.text.clone()) .contained() .with_style(theme.header.container) .aligned() @@ -80,55 +96,50 @@ impl ContactsPanel { .constrained() .with_height(theme.row_height) .boxed() - } else if ix < this.contacts.len() + 1 { - let contact_ix = ix - 1; - Self::render_contact( - this.contacts[contact_ix].clone(), - current_user_id, - app_state.clone(), - theme, - cx, - ) - } else if ix == this.contacts.len() + 1 { - Label::new("add contacts".to_string(), theme.header.text.clone()) - .contained() - .with_style(theme.header.container) - .aligned() - .left() - .constrained() - .with_height(theme.row_height) - .boxed() - } else { - let potential_contact_ix = ix - 2 - this.contacts.len(); - Self::render_potential_contact( - this.potential_contacts[potential_contact_ix].clone(), + } + ContactEntry::IncomingRequest(user) => { + Self::render_incoming_contact_request( + user.clone(), this.user_store.clone(), theme, cx, ) } + ContactEntry::OutgoingRequest(user) => { + Self::render_outgoing_contact_request( + user.clone(), + this.user_store.clone(), + theme, + cx, + ) + } + ContactEntry::Contact(contact) => Self::render_contact( + contact.clone(), + current_user_id, + app_state.clone(), + theme, + cx, + ), + ContactEntry::PotentialContact(user) => Self::render_potential_contact( + user.clone(), + this.user_store.clone(), + theme, + cx, + ), } - }, - ), - contacts: app_state.user_store.read(cx).contacts().into(), - potential_contacts: Default::default(), - user_query_editor, - _maintain_contacts: cx.observe(&app_state.user_store, |this, _, cx| { - this.filter_contacts(false, cx) + } }), + entries: Default::default(), + potential_contacts: Default::default(), + match_candidates: Default::default(), + user_query_editor, + _maintain_contacts: cx + .observe(&app_state.user_store, |this, _, cx| this.update_entries(cx)), contacts_search_task: None, user_store: app_state.user_store.clone(), - } - } - - fn update_list_state(&mut self, cx: &mut ViewContext) { - let mut list_len = 1 + self.contacts.len(); - if !self.potential_contacts.is_empty() { - list_len += 1 + self.potential_contacts.len(); - } - - self.list_state.reset(list_len); - cx.notify(); + }; + this.update_entries(cx); + this } fn render_contact( @@ -295,6 +306,150 @@ impl ContactsPanel { .boxed() } + fn render_incoming_contact_request( + user: Arc, + user_store: ModelHandle, + theme: &theme::ContactsPanel, + cx: &mut LayoutContext, + ) -> ElementBox { + enum Reject {} + enum Accept {} + + let user_id = user.id; + let request_status = user_store.read(cx).contact_request_status(&user); + + let mut row = Flex::row() + .with_children(user.avatar.clone().map(|avatar| { + Image::new(avatar) + .with_style(theme.contact_avatar) + .aligned() + .left() + .boxed() + })) + .with_child( + Label::new( + user.github_login.clone(), + theme.contact_username.text.clone(), + ) + .contained() + .with_style(theme.contact_username.container) + .aligned() + .left() + .boxed(), + ); + + if request_status == ContactRequestStatus::Pending { + row.add_child( + Label::new("…".to_string(), theme.edit_contact.text.clone()) + .contained() + .with_style(theme.edit_contact.container) + .aligned() + .flex_float() + .boxed(), + ); + } else { + row.add_children([ + MouseEventHandler::new::(user.id as usize, cx, |_, _| { + Label::new("Reject".to_string(), theme.edit_contact.text.clone()) + .contained() + .with_style(theme.edit_contact.container) + .aligned() + .flex_float() + .boxed() + }) + .on_click(move |_, cx| { + cx.dispatch_action(RespondToContactRequest { + user_id, + accept: false, + }); + }) + .with_cursor_style(CursorStyle::PointingHand) + .flex_float() + .boxed(), + MouseEventHandler::new::(user.id as usize, cx, |_, _| { + Label::new("Accept".to_string(), theme.edit_contact.text.clone()) + .contained() + .with_style(theme.edit_contact.container) + .aligned() + .flex_float() + .boxed() + }) + .on_click(move |_, cx| { + cx.dispatch_action(RespondToContactRequest { + user_id, + accept: true, + }); + }) + .with_cursor_style(CursorStyle::PointingHand) + .boxed(), + ]); + } + + row.constrained().with_height(theme.row_height).boxed() + } + + fn render_outgoing_contact_request( + user: Arc, + user_store: ModelHandle, + theme: &theme::ContactsPanel, + cx: &mut LayoutContext, + ) -> ElementBox { + enum Cancel {} + + let user_id = user.id; + let request_status = user_store.read(cx).contact_request_status(&user); + + let mut row = Flex::row() + .with_children(user.avatar.clone().map(|avatar| { + Image::new(avatar) + .with_style(theme.contact_avatar) + .aligned() + .left() + .boxed() + })) + .with_child( + Label::new( + user.github_login.clone(), + theme.contact_username.text.clone(), + ) + .contained() + .with_style(theme.contact_username.container) + .aligned() + .left() + .boxed(), + ); + + if request_status == ContactRequestStatus::Pending { + row.add_child( + Label::new("…".to_string(), theme.edit_contact.text.clone()) + .contained() + .with_style(theme.edit_contact.container) + .aligned() + .flex_float() + .boxed(), + ); + } else { + row.add_child( + MouseEventHandler::new::(user.id as usize, cx, |_, _| { + Label::new("Cancel".to_string(), theme.edit_contact.text.clone()) + .contained() + .with_style(theme.edit_contact.container) + .aligned() + .flex_float() + .boxed() + }) + .on_click(move |_, cx| { + cx.dispatch_action(RemoveContact(user_id)); + }) + .with_cursor_style(CursorStyle::PointingHand) + .flex_float() + .boxed(), + ); + } + + row.constrained().with_height(theme.row_height).boxed() + } + fn render_potential_contact( contact: Arc, user_store: ModelHandle, @@ -330,9 +485,11 @@ impl ContactsPanel { cx, |_, _| { let label = match request_status { - ContactRequestStatus::None => "+", - ContactRequestStatus::SendingRequest => "…", - ContactRequestStatus::Requested => "-", + ContactRequestStatus::None | ContactRequestStatus::RequestReceived => { + "+" + } + ContactRequestStatus::Pending => "…", + ContactRequestStatus::RequestSent => "-", ContactRequestStatus::RequestAccepted => unreachable!(), }; @@ -348,7 +505,7 @@ impl ContactsPanel { ContactRequestStatus::None => { cx.dispatch_action(RequestContact(contact.id)); } - ContactRequestStatus::Requested => { + ContactRequestStatus::RequestSent => { cx.dispatch_action(RemoveContact(contact.id)); } _ => {} @@ -361,77 +518,145 @@ impl ContactsPanel { .boxed() } - fn filter_contacts(&mut self, query_changed: bool, cx: &mut ViewContext) { + fn query_changed(&mut self, cx: &mut ViewContext) { + self.update_entries(cx); + let query = self.user_query_editor.read(cx).text(cx); + let search_users = self + .user_store + .update(cx, |store, cx| store.fuzzy_search_users(query, cx)); - if query.is_empty() { - self.contacts.clear(); - self.contacts - .extend_from_slice(self.user_store.read(cx).contacts()); - - if query_changed { - self.potential_contacts.clear(); + self.contacts_search_task = Some(cx.spawn(|this, mut cx| { + async move { + let potential_contacts = search_users.await?; + this.update(&mut cx, |this, cx| { + this.potential_contacts = potential_contacts; + this.update_entries(cx); + }); + Ok(()) } + .log_err() + })); + } - self.update_list_state(cx); - return; + fn update_entries(&mut self, cx: &mut ViewContext) { + let user_store = self.user_store.read(cx); + let query = self.user_query_editor.read(cx).text(cx); + let executor = cx.background().clone(); + + self.entries.clear(); + + let incoming = user_store.incoming_contact_requests(); + if !incoming.is_empty() { + self.match_candidates.clear(); + self.match_candidates + .extend( + incoming + .iter() + .enumerate() + .map(|(ix, user)| StringMatchCandidate { + id: ix, + string: user.github_login.clone(), + char_bag: user.github_login.chars().collect(), + }), + ); + let matches = executor.block(match_strings( + &self.match_candidates, + &query, + true, + usize::MAX, + &Default::default(), + executor.clone(), + )); + if !matches.is_empty() { + self.entries.push(ContactEntry::Header("Requests Received")); + self.entries.extend( + matches.iter().map(|mat| { + ContactEntry::IncomingRequest(incoming[mat.candidate_id].clone()) + }), + ); + } } - let contacts = self.user_store.read(cx).contacts().to_vec(); - let candidates = contacts - .iter() - .enumerate() - .map(|(ix, contact)| StringMatchCandidate { - id: ix, - string: contact.user.github_login.clone(), - char_bag: contact.user.github_login.chars().collect(), - }) - .collect::>(); - let cancel_flag = Default::default(); - let background = cx.background().clone(); - - let search_users = if query_changed { - self.user_store - .update(cx, |store, cx| store.fuzzy_search_users(query.clone(), cx)) - } else { - Task::ready(Ok(self.potential_contacts.clone())) - }; - - let match_contacts = async move { - anyhow::Ok( - fuzzy::match_strings( - &candidates, - query.as_str(), - false, - 100, - &cancel_flag, - background, - ) - .await, - ) - }; - - self.contacts_search_task = Some(cx.spawn(|this, mut cx| async move { - let (contact_matches, users) = - futures::future::join(match_contacts, search_users).await; - let contact_matches = contact_matches.log_err()?; - let users = users.log_err()?; - - this.update(&mut cx, |this, cx| { - let user_store = this.user_store.read(cx); - this.contacts.clear(); - this.contacts.extend( - contact_matches + let outgoing = user_store.outgoing_contact_requests(); + if !outgoing.is_empty() { + self.match_candidates.clear(); + self.match_candidates + .extend( + outgoing .iter() - .map(|mat| contacts[mat.candidate_id].clone()), + .enumerate() + .map(|(ix, user)| StringMatchCandidate { + id: ix, + string: user.github_login.clone(), + char_bag: user.github_login.chars().collect(), + }), ); - this.potential_contacts = users; - this.potential_contacts - .retain(|user| !user_store.has_contact(&user)); - this.update_list_state(cx); - }); - None - })); + let matches = executor.block(match_strings( + &self.match_candidates, + &query, + true, + usize::MAX, + &Default::default(), + executor.clone(), + )); + if !matches.is_empty() { + self.entries.push(ContactEntry::Header("Requests Sent")); + self.entries.extend( + matches.iter().map(|mat| { + ContactEntry::OutgoingRequest(outgoing[mat.candidate_id].clone()) + }), + ); + } + } + + let contacts = user_store.contacts(); + if !contacts.is_empty() { + 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(), + }), + ); + let matches = executor.block(match_strings( + &self.match_candidates, + &query, + true, + usize::MAX, + &Default::default(), + executor.clone(), + )); + if !matches.is_empty() { + self.entries.push(ContactEntry::Header("Contacts")); + self.entries.extend( + matches + .iter() + .map(|mat| ContactEntry::Contact(contacts[mat.candidate_id].clone())), + ); + } + } + + if !self.potential_contacts.is_empty() { + self.entries.push(ContactEntry::Header("Add Contacts")); + self.entries.extend( + self.potential_contacts + .iter() + .map(|user| ContactEntry::PotentialContact(user.clone())), + ); + } + + self.list_state.reset(self.entries.len()); + + log::info!("UPDATE ENTRIES"); + dbg!(&self.entries); + + cx.notify(); } fn request_contact(&mut self, request: &RequestContact, cx: &mut ViewContext) { @@ -445,6 +670,18 @@ impl ContactsPanel { .update(cx, |store, cx| store.remove_contact(request.0, cx)) .detach(); } + + fn respond_to_contact_request( + &mut self, + action: &RespondToContactRequest, + cx: &mut ViewContext, + ) { + self.user_store + .update(cx, |store, cx| { + store.respond_to_contact_request(action.user_id, action.accept, cx) + }) + .detach(); + } } pub enum Event {} diff --git a/crates/fuzzy/src/fuzzy.rs b/crates/fuzzy/src/fuzzy.rs index 7458f27c91..f6abb22ddc 100644 --- a/crates/fuzzy/src/fuzzy.rs +++ b/crates/fuzzy/src/fuzzy.rs @@ -185,6 +185,18 @@ pub async fn match_strings( return Default::default(); } + if query.is_empty() { + return candidates + .iter() + .map(|candidate| StringMatch { + candidate_id: candidate.id, + score: 0., + positions: Default::default(), + string: candidate.string.clone(), + }) + .collect(); + } + let lowercase_query = query.to_lowercase().chars().collect::>(); let query = query.chars().collect::>(); @@ -195,7 +207,7 @@ pub async fn match_strings( let num_cpus = background.num_cpus().min(candidates.len()); let segment_size = (candidates.len() + num_cpus - 1) / num_cpus; let mut segment_results = (0..num_cpus) - .map(|_| Vec::with_capacity(max_results)) + .map(|_| Vec::with_capacity(max_results.min(candidates.len()))) .collect::>(); background