Add the ability to expand and collapse sections of the contacts panel

Max Brunsfeld created

Also, allow joining projects using the keyboard.

Change summary

assets/themes/cave-dark.json                |   1 
assets/themes/cave-light.json               |   1 
assets/themes/dark.json                     |   1 
assets/themes/light.json                    |   1 
assets/themes/solarized-dark.json           |   1 
assets/themes/solarized-light.json          |   1 
assets/themes/sulphurpool-dark.json         |   1 
assets/themes/sulphurpool-light.json        |   1 
crates/contacts_panel/src/contacts_panel.rs | 220 ++++++++++++++++------
crates/theme/src/theme.rs                   |   1 
styles/src/styleTree/contactsPanel.ts       |   1 
11 files changed, 167 insertions(+), 63 deletions(-)

Detailed changes

assets/themes/cave-dark.json 🔗

@@ -1251,6 +1251,7 @@
       "icon_width": 8
     },
     "row_height": 28,
+    "section_icon_size": 8,
     "header_row": {
       "family": "Zed Mono",
       "color": "#8b8792",

assets/themes/cave-light.json 🔗

@@ -1251,6 +1251,7 @@
       "icon_width": 8
     },
     "row_height": 28,
+    "section_icon_size": 8,
     "header_row": {
       "family": "Zed Mono",
       "color": "#585260",

assets/themes/dark.json 🔗

@@ -1251,6 +1251,7 @@
       "icon_width": 8
     },
     "row_height": 28,
+    "section_icon_size": 8,
     "header_row": {
       "family": "Zed Mono",
       "color": "#9c9c9c",

assets/themes/light.json 🔗

@@ -1251,6 +1251,7 @@
       "icon_width": 8
     },
     "row_height": 28,
+    "section_icon_size": 8,
     "header_row": {
       "family": "Zed Mono",
       "color": "#474747",

crates/contacts_panel/src/contacts_panel.rs 🔗

@@ -15,7 +15,7 @@ use serde::Deserialize;
 use settings::Settings;
 use std::sync::Arc;
 use theme::IconButton;
-use workspace::menu::{SelectNext, SelectPrev};
+use workspace::menu::{Confirm, SelectNext, SelectPrev};
 use workspace::{AppState, JoinProject};
 
 impl_actions!(
@@ -23,9 +23,16 @@ impl_actions!(
     [RequestContact, RemoveContact, RespondToContactRequest]
 );
 
+#[derive(Clone, Copy, PartialEq, Eq, Debug, PartialOrd, Ord)]
+enum Section {
+    Requests,
+    Online,
+    Offline,
+}
+
 #[derive(Clone, Debug)]
 enum ContactEntry {
-    Header(&'static str),
+    Header(Section),
     IncomingRequest(Arc<User>),
     OutgoingRequest(Arc<User>),
     Contact(Arc<Contact>),
@@ -38,7 +45,9 @@ pub struct ContactsPanel {
     list_state: ListState,
     user_store: ModelHandle<UserStore>,
     filter_editor: ViewHandle<Editor>,
+    collapsed_sections: Vec<Section>,
     selection: Option<usize>,
+    app_state: Arc<AppState>,
     _maintain_contacts: Subscription,
 }
 
@@ -62,6 +71,7 @@ pub fn init(cx: &mut MutableAppContext) {
     cx.add_action(ContactsPanel::clear_filter);
     cx.add_action(ContactsPanel::select_next);
     cx.add_action(ContactsPanel::select_prev);
+    cx.add_action(ContactsPanel::confirm);
 }
 
 impl ContactsPanel {
@@ -97,18 +107,9 @@ impl ContactsPanel {
                     let is_selected = this.selection == Some(ix);
 
                     match &this.entries[ix] {
-                        ContactEntry::Header(text) => {
-                            let header_style =
-                                theme.header_row.style_for(&Default::default(), is_selected);
-                            Label::new(text.to_string(), header_style.text.clone())
-                                .contained()
-                                .aligned()
-                                .left()
-                                .constrained()
-                                .with_height(theme.row_height)
-                                .contained()
-                                .with_style(header_style.container)
-                                .boxed()
+                        ContactEntry::Header(section) => {
+                            let is_collapsed = this.collapsed_sections.contains(&section);
+                            Self::render_header(*section, theme, is_selected, is_collapsed)
                         }
                         ContactEntry::IncomingRequest(user) => Self::render_contact_request(
                             user.clone(),
@@ -153,17 +154,64 @@ impl ContactsPanel {
                 }
             }),
             selection: None,
+            collapsed_sections: Default::default(),
             entries: Default::default(),
             match_candidates: Default::default(),
             filter_editor: user_query_editor,
             _maintain_contacts: cx
                 .observe(&app_state.user_store, |this, _, cx| this.update_entries(cx)),
             user_store: app_state.user_store.clone(),
+            app_state,
         };
         this.update_entries(cx);
         this
     }
 
+    fn render_header(
+        section: Section,
+        theme: &theme::ContactsPanel,
+        is_selected: bool,
+        is_collapsed: bool,
+    ) -> ElementBox {
+        let header_style = theme.header_row.style_for(&Default::default(), is_selected);
+        let text = match section {
+            Section::Requests => "Requests",
+            Section::Online => "Online",
+            Section::Offline => "Offline",
+        };
+        let icon_size = theme.section_icon_size;
+        Flex::row()
+            .with_child(
+                Svg::new(if is_collapsed {
+                    "icons/disclosure-closed.svg"
+                } else {
+                    "icons/disclosure-open.svg"
+                })
+                .with_color(header_style.text.color)
+                .constrained()
+                .with_max_width(icon_size)
+                .with_max_height(icon_size)
+                .aligned()
+                .constrained()
+                .with_width(icon_size)
+                .boxed(),
+            )
+            .with_child(
+                Label::new(text.to_string(), header_style.text.clone())
+                    .aligned()
+                    .left()
+                    .contained()
+                    .with_margin_left(theme.contact_username.container.margin.left)
+                    .flex(1., true)
+                    .boxed(),
+            )
+            .constrained()
+            .with_height(theme.row_height)
+            .contained()
+            .with_style(header_style.container)
+            .boxed()
+    }
+
     fn render_contact(
         contact: Arc<Contact>,
         theme: &theme::ContactsPanel,
@@ -507,8 +555,10 @@ impl ContactsPanel {
         }
 
         if !request_entries.is_empty() {
-            self.entries.push(ContactEntry::Header("Requests"));
-            self.entries.append(&mut request_entries);
+            self.entries.push(ContactEntry::Header(Section::Requests));
+            if !self.collapsed_sections.contains(&Section::Requests) {
+                self.entries.append(&mut request_entries);
+            }
         }
 
         let contacts = user_store.contacts();
@@ -538,22 +588,27 @@ impl ContactsPanel {
                 .iter()
                 .partition::<Vec<_>, _>(|mat| contacts[mat.candidate_id].online);
 
-            for (matches, name) in [(online_contacts, "Online"), (offline_contacts, "Offline")] {
+            for (matches, section) in [
+                (online_contacts, Section::Online),
+                (offline_contacts, Section::Offline),
+            ] {
                 if !matches.is_empty() {
-                    self.entries.push(ContactEntry::Header(name));
-                    for mat in matches {
-                        let contact = &contacts[mat.candidate_id];
-                        self.entries.push(ContactEntry::Contact(contact.clone()));
-                        self.entries
-                            .extend(contact.projects.iter().enumerate().filter_map(
-                                |(ix, project)| {
-                                    if project.worktree_root_names.is_empty() {
-                                        None
-                                    } else {
-                                        Some(ContactEntry::ContactProject(contact.clone(), ix))
-                                    }
-                                },
-                            ));
+                    self.entries.push(ContactEntry::Header(section));
+                    if !self.collapsed_sections.contains(&section) {
+                        for mat in matches {
+                            let contact = &contacts[mat.candidate_id];
+                            self.entries.push(ContactEntry::Contact(contact.clone()));
+                            self.entries
+                                .extend(contact.projects.iter().enumerate().filter_map(
+                                    |(ix, project)| {
+                                        if project.worktree_root_names.is_empty() {
+                                            None
+                                        } else {
+                                            Some(ContactEntry::ContactProject(contact.clone(), ix))
+                                        }
+                                    },
+                                ));
+                        }
                     }
                 }
             }
@@ -624,6 +679,32 @@ impl ContactsPanel {
         cx.notify();
         self.list_state.reset(self.entries.len());
     }
+
+    fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
+        if let Some(selection) = self.selection {
+            if let Some(entry) = self.entries.get(selection) {
+                match entry {
+                    ContactEntry::Header(section) => {
+                        if let Some(ix) = self.collapsed_sections.iter().position(|s| s == section)
+                        {
+                            self.collapsed_sections.remove(ix);
+                        } else {
+                            self.collapsed_sections.push(*section);
+                        }
+                        self.update_entries(cx);
+                    }
+                    ContactEntry::ContactProject(contact, project_ix) => {
+                        cx.dispatch_global_action(JoinProject {
+                            project_id: contact.projects[*project_ix].id,
+                            app_state: self.app_state.clone(),
+                        })
+                    }
+                    _ => {}
+                }
+            }
+        } else {
+        }
+    }
 }
 
 fn render_icon_button(style: &IconButton, svg_path: &'static str) -> impl Element {
@@ -706,9 +787,9 @@ impl View for ContactsPanel {
 impl PartialEq for ContactEntry {
     fn eq(&self, other: &Self) -> bool {
         match self {
-            ContactEntry::Header(name_1) => {
-                if let ContactEntry::Header(name_2) = other {
-                    return name_1 == name_2;
+            ContactEntry::Header(section_1) => {
+                if let ContactEntry::Header(section_2) = other {
+                    return section_1 == section_2;
                 }
             }
             ContactEntry::IncomingRequest(user_1) => {
@@ -816,7 +897,7 @@ mod tests {
             &[
                 "+",
                 "v Requests",
-                "  incoming user_one  <=== selected",
+                "  incoming user_one",
                 "  outgoing user_two",
                 "v Online",
                 "  user_four",
@@ -838,10 +919,10 @@ mod tests {
             render_to_strings(&panel, cx),
             &[
                 "+",
-                "Online",
-                "  user_four  <=== selected",
+                "v Online",
+                "  user_four",
                 "    dir2",
-                "Offline",
+                "v Offline",
                 "  user_five",
             ]
         );
@@ -853,10 +934,10 @@ mod tests {
             render_to_strings(&panel, cx),
             &[
                 "+",
-                "Online",
+                "v Online  <=== selected",
                 "  user_four",
-                "    dir2  <=== selected",
-                "Offline",
+                "    dir2",
+                "v Offline",
                 "  user_five",
             ]
         );
@@ -868,11 +949,11 @@ mod tests {
             render_to_strings(&panel, cx),
             &[
                 "+",
-                "Online",
-                "  user_four",
+                "v Online",
+                "  user_four  <=== selected",
                 "    dir2",
-                "Offline",
-                "  user_five  <=== selected",
+                "v Offline",
+                "  user_five",
             ]
         );
     }
@@ -881,25 +962,38 @@ mod tests {
         panel.read_with(cx, |panel, _| {
             let mut entries = Vec::new();
             entries.push("+".to_string());
-            entries.extend(panel.entries.iter().map(|entry| match entry {
-                ContactEntry::Header(name) => {
-                    format!("{}", name)
-                }
-                ContactEntry::IncomingRequest(user) => {
-                    format!("  incoming {}", user.github_login)
-                }
-                ContactEntry::OutgoingRequest(user) => {
-                    format!("  outgoing {}", user.github_login)
-                }
-                ContactEntry::Contact(contact) => {
-                    format!("  {}", contact.user.github_login)
-                }
-                ContactEntry::ContactProject(contact, project_ix) => {
-                    format!(
-                        "    {}",
-                        contact.projects[*project_ix].worktree_root_names.join(", ")
-                    )
+            entries.extend(panel.entries.iter().enumerate().map(|(ix, entry)| {
+                let mut string = match entry {
+                    ContactEntry::Header(name) => {
+                        let icon = if panel.collapsed_sections.contains(name) {
+                            ">"
+                        } else {
+                            "v"
+                        };
+                        format!("{} {:?}", icon, name)
+                    }
+                    ContactEntry::IncomingRequest(user) => {
+                        format!("  incoming {}", user.github_login)
+                    }
+                    ContactEntry::OutgoingRequest(user) => {
+                        format!("  outgoing {}", user.github_login)
+                    }
+                    ContactEntry::Contact(contact) => {
+                        format!("  {}", contact.user.github_login)
+                    }
+                    ContactEntry::ContactProject(contact, project_ix) => {
+                        format!(
+                            "    {}",
+                            contact.projects[*project_ix].worktree_root_names.join(", ")
+                        )
+                    }
+                };
+
+                if panel.selection == Some(ix) {
+                    string.push_str("  <=== selected");
                 }
+
+                string
             }));
             entries
         })

crates/theme/src/theme.rs 🔗

@@ -249,6 +249,7 @@ pub struct ContactsPanel {
     pub contact_button_spacing: f32,
     pub disabled_contact_button: IconButton,
     pub tree_branch: Interactive<TreeBranch>,
+    pub section_icon_size: f32,
 }
 
 #[derive(Deserialize, Default, Clone, Copy)]

styles/src/styleTree/contactsPanel.ts 🔗

@@ -68,6 +68,7 @@ export default function contactsPanel(theme: Theme) {
       iconWidth: 8,
     },
     rowHeight: 28,
+    sectionIconSize: 8,
     headerRow: {
       ...text(theme, "mono", "secondary", { size: "sm" }),
       margin: { top: 14 },