Allow fuzzy-search for potential contacts in the contacts panel

Max Brunsfeld and Nathan Sobo created

Co-authored-by: Nathan Sobo <nathan@zed.dev>

Change summary

Cargo.lock                                  |   1 
assets/themes/cave-dark.json                |  11 
assets/themes/cave-light.json               |  11 
assets/themes/dark.json                     |  11 
assets/themes/light.json                    |  11 
assets/themes/solarized-dark.json           |  11 
assets/themes/solarized-light.json          |  11 
assets/themes/sulphurpool-dark.json         |  11 
assets/themes/sulphurpool-light.json        |  11 
crates/client/src/channel.rs                |   8 
crates/client/src/user.rs                   |  85 +++-
crates/collab/src/rpc.rs                    |  31 +
crates/contacts_panel/Cargo.toml            |   1 
crates/contacts_panel/src/contacts_panel.rs | 403 ++++++++++++++--------
crates/project/src/project.rs               |   2 
crates/rpc/proto/zed.proto                  |  17 
crates/rpc/src/proto.rs                     |   6 
crates/theme/src/theme.rs                   |  17 
styles/src/styleTree/contactsPanel.ts       |  12 
19 files changed, 435 insertions(+), 236 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -935,6 +935,7 @@ dependencies = [
  "postage",
  "settings",
  "theme",
+ "util",
  "workspace",
 ]
 

assets/themes/cave-dark.json 🔗

@@ -1240,14 +1240,14 @@
         "top": 7
       }
     },
-    "host_row_height": 28,
+    "row_height": 28,
     "tree_branch_color": "#655f6d",
     "tree_branch_width": 1,
-    "host_avatar": {
+    "contact_avatar": {
       "corner_radius": 10,
       "width": 18
     },
-    "host_username": {
+    "contact_username": {
       "family": "Zed Mono",
       "color": "#e2dfe7",
       "size": 14,
@@ -1255,6 +1255,11 @@
         "left": 8
       }
     },
+    "header": {
+      "family": "Zed Mono",
+      "color": "#8b8792",
+      "size": 14
+    },
     "project": {
       "guest_avatar_spacing": 4,
       "height": 24,

assets/themes/cave-light.json 🔗

@@ -1240,14 +1240,14 @@
         "top": 7
       }
     },
-    "host_row_height": 28,
+    "row_height": 28,
     "tree_branch_color": "#7e7887",
     "tree_branch_width": 1,
-    "host_avatar": {
+    "contact_avatar": {
       "corner_radius": 10,
       "width": 18
     },
-    "host_username": {
+    "contact_username": {
       "family": "Zed Mono",
       "color": "#26232a",
       "size": 14,
@@ -1255,6 +1255,11 @@
         "left": 8
       }
     },
+    "header": {
+      "family": "Zed Mono",
+      "color": "#585260",
+      "size": 14
+    },
     "project": {
       "guest_avatar_spacing": 4,
       "height": 24,

assets/themes/dark.json 🔗

@@ -1240,14 +1240,14 @@
         "top": 7
       }
     },
-    "host_row_height": 28,
+    "row_height": 28,
     "tree_branch_color": "#404040",
     "tree_branch_width": 1,
-    "host_avatar": {
+    "contact_avatar": {
       "corner_radius": 10,
       "width": 18
     },
-    "host_username": {
+    "contact_username": {
       "family": "Zed Mono",
       "color": "#f1f1f1",
       "size": 14,
@@ -1255,6 +1255,11 @@
         "left": 8
       }
     },
+    "header": {
+      "family": "Zed Mono",
+      "color": "#9c9c9c",
+      "size": 14
+    },
     "project": {
       "guest_avatar_spacing": 4,
       "height": 24,

assets/themes/light.json 🔗

@@ -1240,14 +1240,14 @@
         "top": 7
       }
     },
-    "host_row_height": 28,
+    "row_height": 28,
     "tree_branch_color": "#e3e3e3",
     "tree_branch_width": 1,
-    "host_avatar": {
+    "contact_avatar": {
       "corner_radius": 10,
       "width": 18
     },
-    "host_username": {
+    "contact_username": {
       "family": "Zed Mono",
       "color": "#2b2b2b",
       "size": 14,
@@ -1255,6 +1255,11 @@
         "left": 8
       }
     },
+    "header": {
+      "family": "Zed Mono",
+      "color": "#474747",
+      "size": 14
+    },
     "project": {
       "guest_avatar_spacing": 4,
       "height": 24,

assets/themes/solarized-dark.json 🔗

@@ -1240,14 +1240,14 @@
         "top": 7
       }
     },
-    "host_row_height": 28,
+    "row_height": 28,
     "tree_branch_color": "#657b83",
     "tree_branch_width": 1,
-    "host_avatar": {
+    "contact_avatar": {
       "corner_radius": 10,
       "width": 18
     },
-    "host_username": {
+    "contact_username": {
       "family": "Zed Mono",
       "color": "#eee8d5",
       "size": 14,
@@ -1255,6 +1255,11 @@
         "left": 8
       }
     },
+    "header": {
+      "family": "Zed Mono",
+      "color": "#93a1a1",
+      "size": 14
+    },
     "project": {
       "guest_avatar_spacing": 4,
       "height": 24,

assets/themes/solarized-light.json 🔗

@@ -1240,14 +1240,14 @@
         "top": 7
       }
     },
-    "host_row_height": 28,
+    "row_height": 28,
     "tree_branch_color": "#839496",
     "tree_branch_width": 1,
-    "host_avatar": {
+    "contact_avatar": {
       "corner_radius": 10,
       "width": 18
     },
-    "host_username": {
+    "contact_username": {
       "family": "Zed Mono",
       "color": "#073642",
       "size": 14,
@@ -1255,6 +1255,11 @@
         "left": 8
       }
     },
+    "header": {
+      "family": "Zed Mono",
+      "color": "#586e75",
+      "size": 14
+    },
     "project": {
       "guest_avatar_spacing": 4,
       "height": 24,

assets/themes/sulphurpool-dark.json 🔗

@@ -1240,14 +1240,14 @@
         "top": 7
       }
     },
-    "host_row_height": 28,
+    "row_height": 28,
     "tree_branch_color": "#6b7394",
     "tree_branch_width": 1,
-    "host_avatar": {
+    "contact_avatar": {
       "corner_radius": 10,
       "width": 18
     },
-    "host_username": {
+    "contact_username": {
       "family": "Zed Mono",
       "color": "#dfe2f1",
       "size": 14,
@@ -1255,6 +1255,11 @@
         "left": 8
       }
     },
+    "header": {
+      "family": "Zed Mono",
+      "color": "#979db4",
+      "size": 14
+    },
     "project": {
       "guest_avatar_spacing": 4,
       "height": 24,

assets/themes/sulphurpool-light.json 🔗

@@ -1240,14 +1240,14 @@
         "top": 7
       }
     },
-    "host_row_height": 28,
+    "row_height": 28,
     "tree_branch_color": "#898ea4",
     "tree_branch_width": 1,
-    "host_avatar": {
+    "contact_avatar": {
       "corner_radius": 10,
       "width": 18
     },
-    "host_username": {
+    "contact_username": {
       "family": "Zed Mono",
       "color": "#293256",
       "size": 14,
@@ -1255,6 +1255,11 @@
         "left": 8
       }
     },
+    "header": {
+      "family": "Zed Mono",
+      "color": "#5e6687",
+      "size": 14
+    },
     "project": {
       "guest_avatar_spacing": 4,
       "height": 24,

crates/client/src/channel.rs 🔗

@@ -500,7 +500,7 @@ async fn messages_from_proto(
         .collect();
     user_store
         .update(cx, |user_store, cx| {
-            user_store.load_users(unique_user_ids, cx)
+            user_store.get_users(unique_user_ids, cx)
         })
         .await?;
 
@@ -639,7 +639,7 @@ mod tests {
         server
             .respond(
                 get_users.receipt(),
-                proto::GetUsersResponse {
+                proto::UsersResponse {
                     users: vec![proto::User {
                         id: 5,
                         github_login: "nathansobo".into(),
@@ -690,7 +690,7 @@ mod tests {
         server
             .respond(
                 get_users.receipt(),
-                proto::GetUsersResponse {
+                proto::UsersResponse {
                     users: vec![proto::User {
                         id: 6,
                         github_login: "maxbrunsfeld".into(),
@@ -738,7 +738,7 @@ mod tests {
         server
             .respond(
                 get_users.receipt(),
-                proto::GetUsersResponse {
+                proto::UsersResponse {
                     users: vec![proto::User {
                         id: 7,
                         github_login: "as-cii".into(),

crates/client/src/user.rs 🔗

@@ -3,6 +3,7 @@ use anyhow::{anyhow, Result};
 use futures::{future, AsyncReadExt};
 use gpui::{AsyncAppContext, Entity, ImageData, ModelContext, ModelHandle, Task};
 use postage::{prelude::Stream, sink::Sink, watch};
+use rpc::proto::{RequestMessage, UsersResponse};
 use std::{
     collections::{HashMap, HashSet},
     sync::{Arc, Weak},
@@ -121,7 +122,7 @@ impl UserStore {
             user_ids.extend(contact.projects.iter().flat_map(|w| &w.guests).copied());
         }
 
-        let load_users = self.load_users(user_ids.into_iter().collect(), cx);
+        let load_users = self.get_users(user_ids.into_iter().collect(), cx);
         cx.spawn(|this, mut cx| async move {
             load_users.await?;
 
@@ -144,37 +145,27 @@ impl UserStore {
         &self.contacts
     }
 
-    pub fn load_users(
+    pub fn has_contact(&self, user: &Arc<User>) -> bool {
+        self.contacts
+            .binary_search_by_key(&&user.github_login, |contact| &contact.user.github_login)
+            .is_ok()
+    }
+
+    pub fn get_users(
         &mut self,
         mut user_ids: Vec<u64>,
         cx: &mut ModelContext<Self>,
-    ) -> Task<Result<()>> {
-        let rpc = self.client.clone();
-        let http = self.http.clone();
+    ) -> Task<Result<Vec<Arc<User>>>> {
         user_ids.retain(|id| !self.users.contains_key(id));
-        cx.spawn_weak(|this, mut cx| async move {
-            if let Some(rpc) = rpc.upgrade() {
-                if !user_ids.is_empty() {
-                    let response = rpc.request(proto::GetUsers { user_ids }).await?;
-                    let new_users = future::join_all(
-                        response
-                            .users
-                            .into_iter()
-                            .map(|user| User::new(user, http.as_ref())),
-                    )
-                    .await;
+        self.load_users(proto::GetUsers { user_ids }, cx)
+    }
 
-                    if let Some(this) = this.upgrade(&cx) {
-                        this.update(&mut cx, |this, _| {
-                            for user in new_users {
-                                this.users.insert(user.id, Arc::new(user));
-                            }
-                        });
-                    }
-                }
-            }
-            Ok(())
-        })
+    pub fn fuzzy_search_users(
+        &mut self,
+        query: String,
+        cx: &mut ModelContext<Self>,
+    ) -> Task<Result<Vec<Arc<User>>>> {
+        self.load_users(proto::FuzzySearchUsers { query }, cx)
     }
 
     pub fn fetch_user(
@@ -186,7 +177,7 @@ impl UserStore {
             return cx.foreground().spawn(async move { Ok(user) });
         }
 
-        let load_users = self.load_users(vec![user_id], cx);
+        let load_users = self.get_users(vec![user_id], cx);
         cx.spawn(|this, mut cx| async move {
             load_users.await?;
             this.update(&mut cx, |this, _| {
@@ -205,15 +196,47 @@ impl UserStore {
     pub fn watch_current_user(&self) -> watch::Receiver<Option<Arc<User>>> {
         self.current_user.clone()
     }
+
+    fn load_users(
+        &mut self,
+        request: impl RequestMessage<Response = UsersResponse>,
+        cx: &mut ModelContext<Self>,
+    ) -> Task<Result<Vec<Arc<User>>>> {
+        let client = self.client.clone();
+        let http = self.http.clone();
+        cx.spawn_weak(|this, mut cx| async move {
+            if let Some(rpc) = client.upgrade() {
+                let response = rpc.request(request).await?;
+                let users = future::join_all(
+                    response
+                        .users
+                        .into_iter()
+                        .map(|user| User::new(user, http.as_ref())),
+                )
+                .await;
+
+                if let Some(this) = this.upgrade(&cx) {
+                    this.update(&mut cx, |this, _| {
+                        for user in &users {
+                            this.users.insert(user.id, user.clone());
+                        }
+                    });
+                }
+                Ok(users)
+            } else {
+                Ok(Vec::new())
+            }
+        })
+    }
 }
 
 impl User {
-    async fn new(message: proto::User, http: &dyn HttpClient) -> Self {
-        User {
+    async fn new(message: proto::User, http: &dyn HttpClient) -> Arc<Self> {
+        Arc::new(User {
             id: message.id,
             github_login: message.github_login,
             avatar: fetch_avatar(http, &message.avatar_url).warn_on_err().await,
-        }
+        })
     }
 }
 

crates/collab/src/rpc.rs 🔗

@@ -136,6 +136,7 @@ impl Server {
             .add_request_handler(Server::save_buffer)
             .add_request_handler(Server::get_channels)
             .add_request_handler(Server::get_users)
+            .add_request_handler(Server::fuzzy_search_users)
             .add_request_handler(Server::join_channel)
             .add_message_handler(Server::leave_channel)
             .add_request_handler(Server::send_channel_message)
@@ -842,7 +843,7 @@ impl Server {
     async fn get_users(
         self: Arc<Server>,
         request: TypedEnvelope<proto::GetUsers>,
-    ) -> Result<proto::GetUsersResponse> {
+    ) -> Result<proto::UsersResponse> {
         let user_ids = request
             .payload
             .user_ids
@@ -861,7 +862,33 @@ impl Server {
                 github_login: user.github_login,
             })
             .collect();
-        Ok(proto::GetUsersResponse { users })
+        Ok(proto::UsersResponse { users })
+    }
+
+    async fn fuzzy_search_users(
+        self: Arc<Server>,
+        request: TypedEnvelope<proto::FuzzySearchUsers>,
+    ) -> Result<proto::UsersResponse> {
+        let query = request.payload.query;
+        let db = &self.app_state.db;
+        let users = match query.len() {
+            0 => vec![],
+            1 | 2 => db
+                .get_user_by_github_login(&query)
+                .await?
+                .into_iter()
+                .collect(),
+            _ => db.fuzzy_search_users(&query, 10).await?,
+        };
+        let users = users
+            .into_iter()
+            .map(|user| proto::User {
+                id: user.id.to_proto(),
+                avatar_url: format!("https://github.com/{}.png?size=128", user.github_login),
+                github_login: user.github_login,
+            })
+            .collect();
+        Ok(proto::UsersResponse { users })
     }
 
     #[instrument(skip(self, state, user_ids))]

crates/contacts_panel/Cargo.toml 🔗

@@ -13,5 +13,6 @@ editor = { path = "../editor" }
 gpui = { path = "../gpui" }
 settings = { path = "../settings" }
 theme = { path = "../theme" }
+util = { path = "../util" }
 workspace = { path = "../workspace" }
 postage = { version = "0.4.1", features = ["futures-traits"] }

crates/contacts_panel/src/contacts_panel.rs 🔗

@@ -1,71 +1,124 @@
-use client::{Contact, UserStore};
+use client::{Contact, User, UserStore};
 use editor::Editor;
 use gpui::{
     elements::*,
     geometry::{rect::RectF, vector::vec2f},
     platform::CursorStyle,
-    Element, ElementBox, Entity, LayoutContext, ModelHandle, RenderContext, Subscription, View,
-    ViewContext, ViewHandle,
+    Element, ElementBox, Entity, LayoutContext, ModelHandle, RenderContext, Subscription, Task,
+    View, ViewContext, ViewHandle,
 };
 use settings::Settings;
 use std::sync::Arc;
+use util::ResultExt;
 use workspace::{AppState, JoinProject};
 
 pub struct ContactsPanel {
-    contacts: ListState,
+    list_state: ListState,
+    potential_contacts: Vec<Arc<User>>,
     user_store: ModelHandle<UserStore>,
+    contacts_search_task: Option<Task<Option<()>>>,
     user_query_editor: ViewHandle<Editor>,
     _maintain_contacts: Subscription,
 }
 
 impl ContactsPanel {
     pub fn new(app_state: Arc<AppState>, cx: &mut ViewContext<Self>) -> Self {
+        let user_query_editor = cx.add_view(|cx| {
+            Editor::single_line(
+                Some(|theme| theme.contacts_panel.user_query_editor.clone()),
+                cx,
+            )
+        });
+
+        cx.subscribe(&user_query_editor, |this, _, event, cx| {
+            if let editor::Event::BufferEdited = event {
+                this.user_query_changed(cx)
+            }
+        })
+        .detach();
+
         Self {
-            contacts: ListState::new(
-                app_state.user_store.read(cx).contacts().len(),
+            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 user_store = app_state.user_store.read(cx);
+                        let this = this.upgrade(cx).unwrap();
+                        let this = this.read(cx);
+                        let user_store = this.user_store.read(cx);
                         let contacts = user_store.contacts().clone();
                         let current_user_id = user_store.current_user().map(|user| user.id);
-                        Self::render_collaborator(
-                            &contacts[ix],
-                            current_user_id,
-                            app_state.clone(),
-                            cx,
-                        )
+                        let theme = cx.global::<Settings>().theme.clone();
+                        let theme = &theme.contacts_panel;
+
+                        if ix == 0 {
+                            Label::new("contacts".to_string(), theme.header.text.clone())
+                                .contained()
+                                .with_style(theme.header.container)
+                                .aligned()
+                                .left()
+                                .constrained()
+                                .with_height(theme.row_height)
+                                .boxed()
+                        } else if ix < contacts.len() + 1 {
+                            let contact_ix = ix - 1;
+                            Self::render_contact(
+                                &contacts[contact_ix],
+                                current_user_id,
+                                app_state.clone(),
+                                theme,
+                                cx,
+                            )
+                        } else if ix == 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 - contacts.len();
+                            Self::render_potential_contact(
+                                &this.potential_contacts[potential_contact_ix],
+                                theme,
+                            )
+                        }
                     }
                 },
             ),
-            user_query_editor: cx.add_view(|cx| {
-                Editor::single_line(
-                    Some(|theme| theme.contacts_panel.user_query_editor.clone()),
-                    cx,
-                )
+            potential_contacts: Default::default(),
+            user_query_editor,
+            _maintain_contacts: cx.observe(&app_state.user_store, |this, _, cx| {
+                this.update_contacts(cx)
             }),
-            _maintain_contacts: cx.observe(&app_state.user_store, Self::update_contacts),
+            contacts_search_task: None,
             user_store: app_state.user_store.clone(),
         }
     }
 
-    fn update_contacts(&mut self, _: ModelHandle<UserStore>, cx: &mut ViewContext<Self>) {
-        self.contacts
-            .reset(self.user_store.read(cx).contacts().len());
+    fn update_contacts(&mut self, cx: &mut ViewContext<Self>) {
+        let mut list_len = 1 + self.user_store.read(cx).contacts().len();
+        if !self.potential_contacts.is_empty() {
+            list_len += 1 + self.potential_contacts.len();
+        }
+
+        self.list_state.reset(list_len);
         cx.notify();
     }
 
-    fn render_collaborator(
-        collaborator: &Contact,
+    fn render_contact(
+        contact: &Contact,
         current_user_id: Option<u64>,
         app_state: Arc<AppState>,
+        theme: &theme::ContactsPanel,
         cx: &mut LayoutContext,
     ) -> ElementBox {
-        let theme = cx.global::<Settings>().theme.clone();
-        let theme = &theme.contacts_panel;
-        let project_count = collaborator.projects.len();
+        let project_count = contact.projects.len();
         let font_cache = cx.font_cache();
         let line_height = theme.unshared_project.name.text.line_height(font_cache);
         let cap_height = theme.unshared_project.name.text.cap_height(font_cache);
@@ -74,162 +127,202 @@ impl ContactsPanel {
         let tree_branch_width = theme.tree_branch_width;
         let tree_branch_color = theme.tree_branch_color;
         let host_avatar_height = theme
-            .host_avatar
+            .contact_avatar
             .width
-            .or(theme.host_avatar.height)
+            .or(theme.contact_avatar.height)
             .unwrap_or(0.);
 
         Flex::column()
             .with_child(
                 Flex::row()
-                    .with_children(collaborator.user.avatar.clone().map(|avatar| {
+                    .with_children(contact.user.avatar.clone().map(|avatar| {
                         Image::new(avatar)
-                            .with_style(theme.host_avatar)
+                            .with_style(theme.contact_avatar)
                             .aligned()
                             .left()
                             .boxed()
                     }))
                     .with_child(
                         Label::new(
-                            collaborator.user.github_login.clone(),
-                            theme.host_username.text.clone(),
+                            contact.user.github_login.clone(),
+                            theme.contact_username.text.clone(),
                         )
                         .contained()
-                        .with_style(theme.host_username.container)
+                        .with_style(theme.contact_username.container)
                         .aligned()
                         .left()
                         .boxed(),
                     )
                     .constrained()
-                    .with_height(theme.host_row_height)
+                    .with_height(theme.row_height)
                     .boxed(),
             )
-            .with_children(
-                collaborator
-                    .projects
-                    .iter()
-                    .enumerate()
-                    .map(|(ix, project)| {
-                        let project_id = project.id;
+            .with_children(contact.projects.iter().enumerate().map(|(ix, project)| {
+                let project_id = project.id;
 
-                        Flex::row()
-                            .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.);
+                Flex::row()
+                    .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 ix + 1 == project_count {
-                                                    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.,
-                                    });
-                                })
-                                .constrained()
-                                .with_width(host_avatar_height)
-                                .boxed(),
-                            )
-                            .with_child({
-                                let is_host = Some(collaborator.user.id) == current_user_id;
-                                let is_guest = !is_host
-                                    && project
-                                        .guests
-                                        .iter()
-                                        .any(|guest| Some(guest.id) == current_user_id);
-                                let is_shared = project.is_shared;
-                                let app_state = app_state.clone();
+                            cx.scene.push_quad(gpui::Quad {
+                                bounds: RectF::from_points(
+                                    vec2f(start_x, start_y),
+                                    vec2f(
+                                        start_x + tree_branch_width,
+                                        if ix + 1 == project_count {
+                                            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.,
+                            });
+                        })
+                        .constrained()
+                        .with_width(host_avatar_height)
+                        .boxed(),
+                    )
+                    .with_child({
+                        let is_host = Some(contact.user.id) == current_user_id;
+                        let is_guest = !is_host
+                            && project
+                                .guests
+                                .iter()
+                                .any(|guest| Some(guest.id) == current_user_id);
+                        let is_shared = project.is_shared;
+                        let app_state = app_state.clone();
 
-                                MouseEventHandler::new::<ContactsPanel, _, _>(
-                                    project_id as usize,
-                                    cx,
-                                    |mouse_state, _| {
-                                        let style = match (project.is_shared, mouse_state.hovered) {
-                                            (false, false) => &theme.unshared_project,
-                                            (false, true) => &theme.hovered_unshared_project,
-                                            (true, false) => &theme.shared_project,
-                                            (true, true) => &theme.hovered_shared_project,
-                                        };
+                        MouseEventHandler::new::<ContactsPanel, _, _>(
+                            project_id as usize,
+                            cx,
+                            |mouse_state, _| {
+                                let style = match (project.is_shared, mouse_state.hovered) {
+                                    (false, false) => &theme.unshared_project,
+                                    (false, true) => &theme.hovered_unshared_project,
+                                    (true, false) => &theme.shared_project,
+                                    (true, true) => &theme.hovered_shared_project,
+                                };
 
-                                        Flex::row()
-                                            .with_child(
-                                                Label::new(
-                                                    project.worktree_root_names.join(", "),
-                                                    style.name.text.clone(),
-                                                )
-                                                .aligned()
-                                                .left()
-                                                .contained()
-                                                .with_style(style.name.container)
-                                                .boxed(),
-                                            )
-                                            .with_children(project.guests.iter().filter_map(
-                                                |participant| {
-                                                    participant.avatar.clone().map(|avatar| {
-                                                        Image::new(avatar)
-                                                            .with_style(style.guest_avatar)
-                                                            .aligned()
-                                                            .left()
-                                                            .contained()
-                                                            .with_margin_right(
-                                                                style.guest_avatar_spacing,
-                                                            )
-                                                            .boxed()
-                                                    })
-                                                },
-                                            ))
-                                            .contained()
-                                            .with_style(style.container)
-                                            .constrained()
-                                            .with_height(style.height)
-                                            .boxed()
-                                    },
-                                )
-                                .with_cursor_style(if is_host || is_shared {
-                                    CursorStyle::PointingHand
-                                } else {
-                                    CursorStyle::Arrow
-                                })
-                                .on_click(move |_, cx| {
-                                    if !is_host && !is_guest {
-                                        cx.dispatch_global_action(JoinProject {
-                                            project_id,
-                                            app_state: app_state.clone(),
-                                        });
-                                    }
-                                })
-                                .flex(1., true)
-                                .boxed()
-                            })
-                            .constrained()
-                            .with_height(theme.unshared_project.height)
-                            .boxed()
-                    }),
+                                Flex::row()
+                                    .with_child(
+                                        Label::new(
+                                            project.worktree_root_names.join(", "),
+                                            style.name.text.clone(),
+                                        )
+                                        .aligned()
+                                        .left()
+                                        .contained()
+                                        .with_style(style.name.container)
+                                        .boxed(),
+                                    )
+                                    .with_children(project.guests.iter().filter_map(
+                                        |participant| {
+                                            participant.avatar.clone().map(|avatar| {
+                                                Image::new(avatar)
+                                                    .with_style(style.guest_avatar)
+                                                    .aligned()
+                                                    .left()
+                                                    .contained()
+                                                    .with_margin_right(style.guest_avatar_spacing)
+                                                    .boxed()
+                                            })
+                                        },
+                                    ))
+                                    .contained()
+                                    .with_style(style.container)
+                                    .constrained()
+                                    .with_height(style.height)
+                                    .boxed()
+                            },
+                        )
+                        .with_cursor_style(if is_host || is_shared {
+                            CursorStyle::PointingHand
+                        } else {
+                            CursorStyle::Arrow
+                        })
+                        .on_click(move |_, cx| {
+                            if !is_host && !is_guest {
+                                cx.dispatch_global_action(JoinProject {
+                                    project_id,
+                                    app_state: app_state.clone(),
+                                });
+                            }
+                        })
+                        .flex(1., true)
+                        .boxed()
+                    })
+                    .constrained()
+                    .with_height(theme.unshared_project.height)
+                    .boxed()
+            }))
+            .boxed()
+    }
+
+    fn render_potential_contact(contact: &User, theme: &theme::ContactsPanel) -> ElementBox {
+        Flex::row()
+            .with_children(contact.avatar.clone().map(|avatar| {
+                Image::new(avatar)
+                    .with_style(theme.contact_avatar)
+                    .aligned()
+                    .left()
+                    .boxed()
+            }))
+            .with_child(
+                Label::new(
+                    contact.github_login.clone(),
+                    theme.contact_username.text.clone(),
+                )
+                .contained()
+                .with_style(theme.contact_username.container)
+                .aligned()
+                .left()
+                .boxed(),
             )
+            .constrained()
+            .with_height(theme.row_height)
             .boxed()
     }
+
+    fn user_query_changed(&mut self, cx: &mut ViewContext<Self>) {
+        let query = self.user_query_editor.read(cx).text(cx);
+        if query.is_empty() {
+            self.potential_contacts.clear();
+            self.update_contacts(cx);
+            return;
+        }
+
+        let search = self
+            .user_store
+            .update(cx, |store, cx| store.fuzzy_search_users(query, cx));
+        self.contacts_search_task = Some(cx.spawn(|this, mut cx| async move {
+            let users = search.await.log_err()?;
+            this.update(&mut cx, |this, cx| {
+                let user_store = this.user_store.read(cx);
+                this.potential_contacts = users;
+                this.potential_contacts
+                    .retain(|user| !user_store.has_contact(&user));
+                this.update_contacts(cx);
+            });
+            None
+        }));
+    }
 }
 
 pub enum Event {}
@@ -252,7 +345,7 @@ impl View for ContactsPanel {
                         .with_style(theme.user_query_editor.container)
                         .boxed(),
                 )
-                .with_child(List::new(self.contacts.clone()).flex(1., false).boxed())
+                .with_child(List::new(self.list_state.clone()).flex(1., false).boxed())
                 .boxed(),
         )
         .with_style(theme.container)

crates/project/src/project.rs 🔗

@@ -443,7 +443,7 @@ impl Project {
             .map(|peer| peer.user_id)
             .collect();
         user_store
-            .update(cx, |user_store, cx| user_store.load_users(user_ids, cx))
+            .update(cx, |user_store, cx| user_store.get_users(user_ids, cx))
             .await?;
         let mut collaborators = HashMap::default();
         for message in response.collaborators {

crates/rpc/proto/zed.proto 🔗

@@ -87,12 +87,13 @@ message Envelope {
         UpdateContacts update_contacts = 75;
 
         GetUsers get_users = 76;
-        GetUsersResponse get_users_response = 77;
+        FuzzySearchUsers fuzzy_search_users = 77;
+        UsersResponse users_response = 78;
 
-        Follow follow = 78;
-        FollowResponse follow_response = 79;
-        UpdateFollowers update_followers = 80;
-        Unfollow unfollow = 81;
+        Follow follow = 79;
+        FollowResponse follow_response = 80;
+        UpdateFollowers update_followers = 81;
+        Unfollow unfollow = 82;
     }
 }
 
@@ -538,7 +539,11 @@ message GetUsers {
     repeated uint64 user_ids = 1;
 }
 
-message GetUsersResponse {
+message FuzzySearchUsers {
+    string query = 1;
+}
+
+message UsersResponse {
     repeated User users = 1;
 }
 

crates/rpc/src/proto.rs 🔗

@@ -155,6 +155,7 @@ messages!(
     (FollowResponse, Foreground),
     (FormatBuffers, Foreground),
     (FormatBuffersResponse, Foreground),
+    (FuzzySearchUsers, Foreground),
     (GetChannelMessages, Foreground),
     (GetChannelMessagesResponse, Foreground),
     (GetChannels, Foreground),
@@ -172,7 +173,7 @@ messages!(
     (GetProjectSymbols, Background),
     (GetProjectSymbolsResponse, Background),
     (GetUsers, Foreground),
-    (GetUsersResponse, Foreground),
+    (UsersResponse, Foreground),
     (JoinChannel, Foreground),
     (JoinChannelResponse, Foreground),
     (JoinProject, Foreground),
@@ -236,7 +237,8 @@ request_messages!(
     (GetDocumentHighlights, GetDocumentHighlightsResponse),
     (GetReferences, GetReferencesResponse),
     (GetProjectSymbols, GetProjectSymbolsResponse),
-    (GetUsers, GetUsersResponse),
+    (FuzzySearchUsers, UsersResponse),
+    (GetUsers, UsersResponse),
     (JoinChannel, JoinChannelResponse),
     (JoinProject, JoinProjectResponse),
     (OpenBufferById, OpenBufferResponse),

crates/theme/src/theme.rs 🔗

@@ -234,20 +234,21 @@ pub struct CommandPalette {
 pub struct ContactsPanel {
     #[serde(flatten)]
     pub container: ContainerStyle,
+    pub header: ContainedText,
     pub user_query_editor: FieldEditor,
-    pub host_row_height: f32,
-    pub host_avatar: ImageStyle,
-    pub host_username: ContainedText,
+    pub row_height: f32,
+    pub contact_avatar: ImageStyle,
+    pub contact_username: ContainedText,
     pub tree_branch_width: f32,
     pub tree_branch_color: Color,
-    pub shared_project: WorktreeRow,
-    pub hovered_shared_project: WorktreeRow,
-    pub unshared_project: WorktreeRow,
-    pub hovered_unshared_project: WorktreeRow,
+    pub shared_project: ProjectRow,
+    pub hovered_shared_project: ProjectRow,
+    pub unshared_project: ProjectRow,
+    pub hovered_unshared_project: ProjectRow,
 }
 
 #[derive(Deserialize, Default)]
-pub struct WorktreeRow {
+pub struct ProjectRow {
     #[serde(flatten)]
     pub container: ContainerStyle,
     pub height: f32,

styles/src/styleTree/contactsPanel.ts 🔗

@@ -47,19 +47,25 @@ export default function(theme: Theme) {
         top: 7,
       },
     },
-    hostRowHeight: 28,
+    rowHeight: 28,
     treeBranchColor: borderColor(theme, "muted"),
     treeBranchWidth: 1,
-    hostAvatar: {
+    contactAvatar: {
       cornerRadius: 10,
       width: 18,
     },
-    hostUsername: {
+    contactUsername: {
       ...text(theme, "mono", "primary", { size: "sm" }),
       padding: {
         left: 8,
       },
     },
+    header: {
+      ...text(theme, "mono", "secondary", { size: "sm" }),
+      // padding: {
+      //   left: 8,
+      // }
+    },
     project,
     sharedProject,
     hoveredSharedProject: {