Pull out contact finder as a picker

Antonio Scandurra and Nathan Sobo created

Co-Authored-By: Nathan Sobo <nathan@zed.dev>

Change summary

Cargo.lock                                  |   1 
assets/themes/cave-dark.json                |  33 --
assets/themes/cave-light.json               |  33 --
assets/themes/dark.json                     |  33 --
assets/themes/light.json                    |  33 --
assets/themes/solarized-dark.json           |  33 --
assets/themes/solarized-light.json          |  33 --
assets/themes/sulphurpool-dark.json         |  33 --
assets/themes/sulphurpool-light.json        |  33 --
crates/contacts_panel/Cargo.toml            |   1 
crates/contacts_panel/src/contact_finder.rs | 254 +++++++++++-----------
crates/contacts_panel/src/contacts_panel.rs |  20 -
crates/theme/src/theme.rs                   |   7 
styles/src/styleTree/contactFinder.ts       |  19 -
14 files changed, 140 insertions(+), 426 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -935,6 +935,7 @@ dependencies = [
  "fuzzy",
  "gpui",
  "log",
+ "picker",
  "postage",
  "serde",
  "settings",

assets/themes/cave-dark.json 🔗

@@ -1466,45 +1466,12 @@
         2
       ]
     },
-    "max_width": 540,
-    "max_height": 420,
-    "query_editor": {
-      "background": "#19171c",
-      "corner_radius": 6,
-      "text": {
-        "family": "Zed Mono",
-        "color": "#e2dfe7",
-        "size": 14
-      },
-      "placeholder_text": {
-        "family": "Zed Mono",
-        "color": "#7e7887",
-        "size": 14
-      },
-      "selection": {
-        "cursor": "#576ddb",
-        "selection": "#576ddb3d"
-      },
-      "border": {
-        "color": "#26232a",
-        "width": 1
-      },
-      "padding": {
-        "bottom": 4,
-        "left": 8,
-        "right": 8,
-        "top": 4
-      }
-    },
     "row_height": 28,
     "contact_avatar": {
       "corner_radius": 10,
       "width": 18
     },
     "contact_username": {
-      "family": "Zed Mono",
-      "color": "#e2dfe7",
-      "size": 14,
       "padding": {
         "left": 8
       }

assets/themes/cave-light.json 🔗

@@ -1466,45 +1466,12 @@
         2
       ]
     },
-    "max_width": 540,
-    "max_height": 420,
-    "query_editor": {
-      "background": "#efecf4",
-      "corner_radius": 6,
-      "text": {
-        "family": "Zed Mono",
-        "color": "#26232a",
-        "size": 14
-      },
-      "placeholder_text": {
-        "family": "Zed Mono",
-        "color": "#655f6d",
-        "size": 14
-      },
-      "selection": {
-        "cursor": "#576ddb",
-        "selection": "#576ddb3d"
-      },
-      "border": {
-        "color": "#e2dfe7",
-        "width": 1
-      },
-      "padding": {
-        "bottom": 4,
-        "left": 8,
-        "right": 8,
-        "top": 4
-      }
-    },
     "row_height": 28,
     "contact_avatar": {
       "corner_radius": 10,
       "width": 18
     },
     "contact_username": {
-      "family": "Zed Mono",
-      "color": "#26232a",
-      "size": 14,
       "padding": {
         "left": 8
       }

assets/themes/dark.json 🔗

@@ -1466,45 +1466,12 @@
         2
       ]
     },
-    "max_width": 540,
-    "max_height": 420,
-    "query_editor": {
-      "background": "#000000",
-      "corner_radius": 6,
-      "text": {
-        "family": "Zed Mono",
-        "color": "#f1f1f1",
-        "size": 14
-      },
-      "placeholder_text": {
-        "family": "Zed Mono",
-        "color": "#474747",
-        "size": 14
-      },
-      "selection": {
-        "cursor": "#2472f2",
-        "selection": "#2472f23d"
-      },
-      "border": {
-        "color": "#232323",
-        "width": 1
-      },
-      "padding": {
-        "bottom": 4,
-        "left": 8,
-        "right": 8,
-        "top": 4
-      }
-    },
     "row_height": 28,
     "contact_avatar": {
       "corner_radius": 10,
       "width": 18
     },
     "contact_username": {
-      "family": "Zed Mono",
-      "color": "#f1f1f1",
-      "size": 14,
       "padding": {
         "left": 8
       }

assets/themes/light.json 🔗

@@ -1466,45 +1466,12 @@
         2
       ]
     },
-    "max_width": 540,
-    "max_height": 420,
-    "query_editor": {
-      "background": "#ffffff",
-      "corner_radius": 6,
-      "text": {
-        "family": "Zed Mono",
-        "color": "#2b2b2b",
-        "size": 14
-      },
-      "placeholder_text": {
-        "family": "Zed Mono",
-        "color": "#808080",
-        "size": 14
-      },
-      "selection": {
-        "cursor": "#2472f2",
-        "selection": "#2472f23d"
-      },
-      "border": {
-        "color": "#d5d5d5",
-        "width": 1
-      },
-      "padding": {
-        "bottom": 4,
-        "left": 8,
-        "right": 8,
-        "top": 4
-      }
-    },
     "row_height": 28,
     "contact_avatar": {
       "corner_radius": 10,
       "width": 18
     },
     "contact_username": {
-      "family": "Zed Mono",
-      "color": "#2b2b2b",
-      "size": 14,
       "padding": {
         "left": 8
       }

assets/themes/solarized-dark.json 🔗

@@ -1466,45 +1466,12 @@
         2
       ]
     },
-    "max_width": 540,
-    "max_height": 420,
-    "query_editor": {
-      "background": "#002b36",
-      "corner_radius": 6,
-      "text": {
-        "family": "Zed Mono",
-        "color": "#eee8d5",
-        "size": 14
-      },
-      "placeholder_text": {
-        "family": "Zed Mono",
-        "color": "#839496",
-        "size": 14
-      },
-      "selection": {
-        "cursor": "#268bd2",
-        "selection": "#268bd23d"
-      },
-      "border": {
-        "color": "#073642",
-        "width": 1
-      },
-      "padding": {
-        "bottom": 4,
-        "left": 8,
-        "right": 8,
-        "top": 4
-      }
-    },
     "row_height": 28,
     "contact_avatar": {
       "corner_radius": 10,
       "width": 18
     },
     "contact_username": {
-      "family": "Zed Mono",
-      "color": "#eee8d5",
-      "size": 14,
       "padding": {
         "left": 8
       }

assets/themes/solarized-light.json 🔗

@@ -1466,45 +1466,12 @@
         2
       ]
     },
-    "max_width": 540,
-    "max_height": 420,
-    "query_editor": {
-      "background": "#fdf6e3",
-      "corner_radius": 6,
-      "text": {
-        "family": "Zed Mono",
-        "color": "#073642",
-        "size": 14
-      },
-      "placeholder_text": {
-        "family": "Zed Mono",
-        "color": "#657b83",
-        "size": 14
-      },
-      "selection": {
-        "cursor": "#268bd2",
-        "selection": "#268bd23d"
-      },
-      "border": {
-        "color": "#eee8d5",
-        "width": 1
-      },
-      "padding": {
-        "bottom": 4,
-        "left": 8,
-        "right": 8,
-        "top": 4
-      }
-    },
     "row_height": 28,
     "contact_avatar": {
       "corner_radius": 10,
       "width": 18
     },
     "contact_username": {
-      "family": "Zed Mono",
-      "color": "#073642",
-      "size": 14,
       "padding": {
         "left": 8
       }

assets/themes/sulphurpool-dark.json 🔗

@@ -1466,45 +1466,12 @@
         2
       ]
     },
-    "max_width": 540,
-    "max_height": 420,
-    "query_editor": {
-      "background": "#202746",
-      "corner_radius": 6,
-      "text": {
-        "family": "Zed Mono",
-        "color": "#dfe2f1",
-        "size": 14
-      },
-      "placeholder_text": {
-        "family": "Zed Mono",
-        "color": "#898ea4",
-        "size": 14
-      },
-      "selection": {
-        "cursor": "#3d8fd1",
-        "selection": "#3d8fd13d"
-      },
-      "border": {
-        "color": "#293256",
-        "width": 1
-      },
-      "padding": {
-        "bottom": 4,
-        "left": 8,
-        "right": 8,
-        "top": 4
-      }
-    },
     "row_height": 28,
     "contact_avatar": {
       "corner_radius": 10,
       "width": 18
     },
     "contact_username": {
-      "family": "Zed Mono",
-      "color": "#dfe2f1",
-      "size": 14,
       "padding": {
         "left": 8
       }

assets/themes/sulphurpool-light.json 🔗

@@ -1466,45 +1466,12 @@
         2
       ]
     },
-    "max_width": 540,
-    "max_height": 420,
-    "query_editor": {
-      "background": "#f5f7ff",
-      "corner_radius": 6,
-      "text": {
-        "family": "Zed Mono",
-        "color": "#293256",
-        "size": 14
-      },
-      "placeholder_text": {
-        "family": "Zed Mono",
-        "color": "#6b7394",
-        "size": 14
-      },
-      "selection": {
-        "cursor": "#3d8fd1",
-        "selection": "#3d8fd13d"
-      },
-      "border": {
-        "color": "#dfe2f1",
-        "width": 1
-      },
-      "padding": {
-        "bottom": 4,
-        "left": 8,
-        "right": 8,
-        "top": 4
-      }
-    },
     "row_height": 28,
     "contact_avatar": {
       "corner_radius": 10,
       "width": 18
     },
     "contact_username": {
-      "family": "Zed Mono",
-      "color": "#293256",
-      "size": 14,
       "padding": {
         "left": 8
       }

crates/contacts_panel/Cargo.toml 🔗

@@ -12,6 +12,7 @@ client = { path = "../client" }
 editor = { path = "../editor" }
 fuzzy = { path = "../fuzzy" }
 gpui = { path = "../gpui" }
+picker = { path = "../picker" }
 settings = { path = "../settings" }
 theme = { path = "../theme" }
 util = { path = "../util" }

crates/contacts_panel/src/contact_finder.rs 🔗

@@ -1,25 +1,34 @@
 use client::{ContactRequestStatus, User, UserStore};
-use editor::Editor;
 use gpui::{
-    color::Color, elements::*, platform::CursorStyle, Entity, LayoutContext, ModelHandle,
-    RenderContext, Task, View, ViewContext, ViewHandle,
+    actions, elements::*, Entity, ModelHandle, MutableAppContext, RenderContext, Task, View,
+    ViewContext, ViewHandle,
 };
+use picker::{Picker, PickerDelegate};
 use settings::Settings;
 use std::sync::Arc;
 use util::TryFutureExt;
+use workspace::Workspace;
 
-use crate::{RemoveContact, RequestContact};
+actions!(contact_finder, [Toggle]);
+
+pub fn init(cx: &mut MutableAppContext) {
+    Picker::<ContactFinder>::init(cx);
+    cx.add_action(ContactFinder::toggle);
+}
 
 pub struct ContactFinder {
-    query_editor: ViewHandle<Editor>,
-    list_state: UniformListState,
+    picker: ViewHandle<Picker<Self>>,
     potential_contacts: Arc<[Arc<User>]>,
     user_store: ModelHandle<UserStore>,
-    contacts_search_task: Option<Task<Option<()>>>,
+    selected_index: usize,
+}
+
+pub enum Event {
+    Dismissed,
 }
 
 impl Entity for ContactFinder {
-    type Event = ();
+    type Event = Event;
 }
 
 impl View for ContactFinder {
@@ -27,149 +36,150 @@ impl View for ContactFinder {
         "ContactFinder"
     }
 
-    fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
-        let theme = cx.global::<Settings>().theme.clone();
-        let user_store = self.user_store.clone();
-        let potential_contacts = self.potential_contacts.clone();
-        Flex::column()
-            .with_child(
-                ChildView::new(self.query_editor.clone())
-                    .contained()
-                    .with_style(theme.contact_finder.query_editor.container)
-                    .boxed(),
-            )
-            .with_child(
-                UniformList::new(self.list_state.clone(), self.potential_contacts.len(), {
-                    let theme = theme.clone();
-                    move |range, items, cx| {
-                        items.extend(range.map(|ix| {
-                            Self::render_potential_contact(
-                                &potential_contacts[ix],
-                                &user_store,
-                                &theme.contact_finder,
-                                cx,
-                            )
-                        }))
-                    }
-                })
-                .flex(1., false)
-                .boxed(),
-            )
-            .contained()
-            .with_style(theme.contact_finder.container)
-            .constrained()
-            .with_max_width(theme.contact_finder.max_width)
-            .with_max_height(theme.contact_finder.max_height)
-            .boxed()
+    fn render(&mut self, _: &mut RenderContext<Self>) -> ElementBox {
+        ChildView::new(self.picker.clone()).boxed()
+    }
+
+    fn on_focus(&mut self, cx: &mut ViewContext<Self>) {
+        cx.focus(&self.picker);
     }
 }
 
-impl ContactFinder {
-    pub fn new(user_store: ModelHandle<UserStore>, cx: &mut ViewContext<Self>) -> Self {
-        let query_editor = cx.add_view(|cx| {
-            Editor::single_line(Some(|theme| theme.contact_finder.query_editor.clone()), cx)
-        });
+impl PickerDelegate for ContactFinder {
+    fn match_count(&self) -> usize {
+        self.potential_contacts.len()
+    }
 
-        cx.subscribe(&query_editor, |this, _, event, cx| {
-            if let editor::Event::BufferEdited = event {
-                this.query_changed(cx)
+    fn selected_index(&self) -> usize {
+        self.selected_index
+    }
+
+    fn set_selected_index(&mut self, ix: usize, _: &mut ViewContext<Self>) {
+        self.selected_index = ix;
+    }
+
+    fn update_matches(&mut self, query: String, cx: &mut ViewContext<Self>) -> Task<()> {
+        let search_users = self
+            .user_store
+            .update(cx, |store, cx| store.fuzzy_search_users(query, cx));
+
+        cx.spawn(|this, mut cx| async move {
+            async {
+                let potential_contacts = search_users.await?;
+                this.update(&mut cx, |this, cx| {
+                    this.potential_contacts = potential_contacts.into();
+                    cx.notify();
+                });
+                Ok(())
             }
+            .log_err()
+            .await;
         })
-        .detach();
-        Self {
-            query_editor,
-            list_state: Default::default(),
-            potential_contacts: Arc::from([]),
-            user_store,
-            contacts_search_task: None,
-        }
     }
 
-    fn render_potential_contact(
-        contact: &User,
-        user_store: &ModelHandle<UserStore>,
-        theme: &theme::ContactFinder,
-        cx: &mut LayoutContext,
-    ) -> ElementBox {
-        enum RequestContactButton {}
+    fn confirm(&mut self, cx: &mut ViewContext<Self>) {
+        if let Some(user) = self.potential_contacts.get(self.selected_index) {
+            let user_store = self.user_store.read(cx);
+            match user_store.contact_request_status(user) {
+                ContactRequestStatus::None | ContactRequestStatus::RequestReceived => {
+                    self.user_store
+                        .update(cx, |store, cx| store.request_contact(user.id, cx))
+                        .detach();
+                }
+                ContactRequestStatus::RequestSent => {
+                    self.user_store
+                        .update(cx, |store, cx| store.remove_contact(user.id, cx))
+                        .detach();
+                }
+                _ => {}
+            }
+        }
+    }
 
-        let contact_id = contact.id;
-        let request_status = user_store.read(cx).contact_request_status(&contact);
+    fn dismiss(&mut self, cx: &mut ViewContext<Self>) {
+        cx.emit(Event::Dismissed);
+    }
 
+    fn render_match(
+        &self,
+        ix: usize,
+        mouse_state: &MouseState,
+        selected: bool,
+        cx: &gpui::AppContext,
+    ) -> ElementBox {
+        let theme = &cx.global::<Settings>().theme;
+        let contact = &self.potential_contacts[ix];
+        let request_status = self.user_store.read(cx).contact_request_status(&contact);
+        let label = match request_status {
+            ContactRequestStatus::None | ContactRequestStatus::RequestReceived => "+",
+            ContactRequestStatus::RequestSent => "-",
+            ContactRequestStatus::Pending | ContactRequestStatus::RequestAccepted => "…",
+        };
+        let style = theme.picker.item.style_for(mouse_state, selected);
         Flex::row()
             .with_children(contact.avatar.clone().map(|avatar| {
                 Image::new(avatar)
-                    .with_style(theme.contact_avatar)
+                    .with_style(theme.contact_finder.contact_avatar)
                     .aligned()
                     .left()
                     .boxed()
             }))
+            .with_child(
+                Label::new(contact.github_login.clone(), style.label.clone())
+                    .contained()
+                    .with_style(theme.contact_finder.contact_username)
+                    .aligned()
+                    .left()
+                    .boxed(),
+            )
             .with_child(
                 Label::new(
-                    contact.github_login.clone(),
-                    theme.contact_username.text.clone(),
+                    label.to_string(),
+                    theme.contact_finder.contact_button.text.clone(),
                 )
                 .contained()
-                .with_style(theme.contact_username.container)
+                .with_style(theme.contact_finder.contact_button.container)
                 .aligned()
-                .left()
-                .boxed(),
-            )
-            .with_child(
-                MouseEventHandler::new::<RequestContactButton, _, _>(
-                    contact.id as usize,
-                    cx,
-                    |_, _| {
-                        let label = match request_status {
-                            ContactRequestStatus::None | ContactRequestStatus::RequestReceived => {
-                                "+"
-                            }
-                            ContactRequestStatus::RequestSent => "-",
-                            ContactRequestStatus::Pending
-                            | ContactRequestStatus::RequestAccepted => "…",
-                        };
-
-                        Label::new(label.to_string(), theme.contact_button.text.clone())
-                            .contained()
-                            .with_style(theme.contact_button.container)
-                            .aligned()
-                            .flex_float()
-                            .boxed()
-                    },
-                )
-                .on_click(move |_, cx| match request_status {
-                    ContactRequestStatus::None => {
-                        cx.dispatch_action(RequestContact(contact_id));
-                    }
-                    ContactRequestStatus::RequestSent => {
-                        cx.dispatch_action(RemoveContact(contact_id));
-                    }
-                    _ => {}
-                })
-                .with_cursor_style(CursorStyle::PointingHand)
+                .flex_float()
                 .boxed(),
             )
+            .contained()
+            .with_style(style.container)
             .constrained()
-            .with_height(theme.row_height)
+            .with_height(theme.contact_finder.row_height)
             .boxed()
     }
+}
 
-    fn query_changed(&mut self, cx: &mut ViewContext<Self>) {
-        let query = self.query_editor.read(cx).text(cx);
-        let search_users = self
-            .user_store
-            .update(cx, |store, cx| store.fuzzy_search_users(query, cx));
+impl ContactFinder {
+    fn toggle(workspace: &mut Workspace, _: &Toggle, cx: &mut ViewContext<Workspace>) {
+        workspace.toggle_modal(cx, |cx, workspace| {
+            let finder = cx.add_view(|cx| Self::new(workspace.user_store().clone(), cx));
+            cx.subscribe(&finder, Self::on_event).detach();
+            finder
+        });
+    }
 
-        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.into();
-                    cx.notify();
-                });
-                Ok(())
+    pub fn new(user_store: ModelHandle<UserStore>, cx: &mut ViewContext<Self>) -> Self {
+        let this = cx.weak_handle();
+        Self {
+            picker: cx.add_view(|cx| Picker::new(this, cx)),
+            potential_contacts: Arc::from([]),
+            user_store,
+            selected_index: 0,
+        }
+    }
+
+    fn on_event(
+        workspace: &mut Workspace,
+        _: ViewHandle<Self>,
+        event: &Event,
+        cx: &mut ViewContext<Workspace>,
+    ) {
+        match event {
+            Event::Dismissed => {
+                workspace.dismiss_modal(cx);
             }
-            .log_err()
-        }));
+        }
     }
 }

crates/contacts_panel/src/contacts_panel.rs 🔗

@@ -1,11 +1,9 @@
 mod contact_finder;
 
 use client::{Contact, ContactRequestStatus, User, UserStore};
-use contact_finder::ContactFinder;
 use editor::Editor;
 use fuzzy::{match_strings, StringMatchCandidate};
 use gpui::{
-    actions,
     elements::*,
     geometry::{rect::RectF, vector::vec2f},
     impl_actions,
@@ -16,9 +14,8 @@ use gpui::{
 use serde::Deserialize;
 use settings::Settings;
 use std::sync::Arc;
-use workspace::{AppState, JoinProject, Workspace};
+use workspace::{AppState, JoinProject};
 
-actions!(contacts_panel, [FindNewContacts]);
 impl_actions!(
     contacts_panel,
     [RequestContact, RemoveContact, RespondToContactRequest]
@@ -54,10 +51,10 @@ pub struct RespondToContactRequest {
 }
 
 pub fn init(cx: &mut MutableAppContext) {
+    contact_finder::init(cx);
     cx.add_action(ContactsPanel::request_contact);
     cx.add_action(ContactsPanel::remove_contact);
     cx.add_action(ContactsPanel::respond_to_contact_request);
-    cx.add_action(ContactsPanel::find_new_contacts);
 }
 
 impl ContactsPanel {
@@ -588,16 +585,6 @@ impl ContactsPanel {
             })
             .detach();
     }
-
-    fn find_new_contacts(
-        workspace: &mut Workspace,
-        _: &FindNewContacts,
-        cx: &mut ViewContext<Workspace>,
-    ) {
-        workspace.toggle_modal(cx, |cx, workspace| {
-            cx.add_view(|cx| ContactFinder::new(workspace.user_store().clone(), cx))
-        });
-    }
 }
 
 pub enum Event {}
@@ -638,7 +625,8 @@ impl View for ContactsPanel {
                                     .aligned()
                                     .boxed()
                             })
-                            .on_click(|_, cx| cx.dispatch_action(FindNewContacts))
+                            .with_cursor_style(CursorStyle::PointingHand)
+                            .on_click(|_, cx| cx.dispatch_action(contact_finder::Toggle))
                             .boxed(),
                         )
                         .constrained()

crates/theme/src/theme.rs 🔗

@@ -252,14 +252,9 @@ pub struct ContactsPanel {
 
 #[derive(Deserialize, Default)]
 pub struct ContactFinder {
-    #[serde(flatten)]
-    pub container: ContainerStyle,
-    pub max_width: f32,
-    pub max_height: f32,
-    pub query_editor: FieldEditor,
     pub row_height: f32,
     pub contact_avatar: ImageStyle,
-    pub contact_username: ContainedText,
+    pub contact_username: ContainerStyle,
     pub contact_button: ContainedText,
 }
 

styles/src/styleTree/contactFinder.ts 🔗

@@ -1,33 +1,16 @@
 import Theme from "../themes/theme";
 import picker from "./picker";
-import { backgroundColor, border, player, text } from "./components";
+import { backgroundColor, text } from "./components";
 
 export default function contactFinder(theme: Theme) {
   return {
     ...picker(theme),
-    maxWidth: 540.,
-    maxHeight: 420.,
-    queryEditor: {
-      background: backgroundColor(theme, 500),
-      cornerRadius: 6,
-      text: text(theme, "mono", "primary"),
-      placeholderText: text(theme, "mono", "placeholder", { size: "sm" }),
-      selection: player(theme, 1).selection,
-      border: border(theme, "secondary"),
-      padding: {
-        bottom: 4,
-        left: 8,
-        right: 8,
-        top: 4,
-      },
-    },
     rowHeight: 28,
     contactAvatar: {
       cornerRadius: 10,
       width: 18,
     },
     contactUsername: {
-      ...text(theme, "mono", "primary", { size: "sm" }),
       padding: {
         left: 8,
       },