From 72e7079005a9583ec5d921427aeb4e383adb9d0a Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Wed, 11 May 2022 17:28:35 -0700 Subject: [PATCH] Add the ability to expand and collapse sections of the contacts panel Also, allow joining projects using the keyboard. --- 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 | 264 +++++++++++++------- crates/theme/src/theme.rs | 1 + styles/src/styleTree/contactsPanel.ts | 1 + 11 files changed, 189 insertions(+), 85 deletions(-) diff --git a/assets/themes/cave-dark.json b/assets/themes/cave-dark.json index a944d171fa..fa837acb6a 100644 --- a/assets/themes/cave-dark.json +++ b/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", diff --git a/assets/themes/cave-light.json b/assets/themes/cave-light.json index 47f397a017..6090e856b6 100644 --- a/assets/themes/cave-light.json +++ b/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", diff --git a/assets/themes/dark.json b/assets/themes/dark.json index f9aeb3e3b1..70d58fc640 100644 --- a/assets/themes/dark.json +++ b/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", diff --git a/assets/themes/light.json b/assets/themes/light.json index 6a9624b587..3a3e5e2628 100644 --- a/assets/themes/light.json +++ b/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", diff --git a/assets/themes/solarized-dark.json b/assets/themes/solarized-dark.json index d360fea75c..008b07ed96 100644 --- a/assets/themes/solarized-dark.json +++ b/assets/themes/solarized-dark.json @@ -1251,6 +1251,7 @@ "icon_width": 8 }, "row_height": 28, + "section_icon_size": 8, "header_row": { "family": "Zed Mono", "color": "#93a1a1", diff --git a/assets/themes/solarized-light.json b/assets/themes/solarized-light.json index 0f93014911..60ac66e5ed 100644 --- a/assets/themes/solarized-light.json +++ b/assets/themes/solarized-light.json @@ -1251,6 +1251,7 @@ "icon_width": 8 }, "row_height": 28, + "section_icon_size": 8, "header_row": { "family": "Zed Mono", "color": "#586e75", diff --git a/assets/themes/sulphurpool-dark.json b/assets/themes/sulphurpool-dark.json index ab316085b2..7add13add7 100644 --- a/assets/themes/sulphurpool-dark.json +++ b/assets/themes/sulphurpool-dark.json @@ -1251,6 +1251,7 @@ "icon_width": 8 }, "row_height": 28, + "section_icon_size": 8, "header_row": { "family": "Zed Mono", "color": "#979db4", diff --git a/assets/themes/sulphurpool-light.json b/assets/themes/sulphurpool-light.json index 56a2468b77..169d4a5bfa 100644 --- a/assets/themes/sulphurpool-light.json +++ b/assets/themes/sulphurpool-light.json @@ -1251,6 +1251,7 @@ "icon_width": 8 }, "row_height": 28, + "section_icon_size": 8, "header_row": { "family": "Zed Mono", "color": "#5e6687", diff --git a/crates/contacts_panel/src/contacts_panel.rs b/crates/contacts_panel/src/contacts_panel.rs index 9c8e4b1d12..caa2b34143 100644 --- a/crates/contacts_panel/src/contacts_panel.rs +++ b/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), OutgoingRequest(Arc), Contact(Arc), @@ -38,7 +45,9 @@ pub struct ContactsPanel { list_state: ListState, user_store: ModelHandle, filter_editor: ViewHandle, + collapsed_sections: Vec
, selection: Option, + app_state: Arc, _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(§ion); + 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, 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::, _>(|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(§ion) { + 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) { + 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,68 +919,81 @@ mod tests { render_to_strings(&panel, cx), &[ "+", - "Online", + "v Online", + " user_four", + " dir2", + "v Offline", + " user_five", + ] + ); + + panel.update(cx, |panel, cx| { + panel.select_next(&Default::default(), cx); + }); + assert_eq!( + render_to_strings(&panel, cx), + &[ + "+", + "v Online <=== selected", + " user_four", + " dir2", + "v Offline", + " user_five", + ] + ); + + panel.update(cx, |panel, cx| { + panel.select_next(&Default::default(), cx); + }); + assert_eq!( + render_to_strings(&panel, cx), + &[ + "+", + "v Online", " user_four <=== selected", " dir2", - "Offline", + "v Offline", " user_five", ] ); - - panel.update(cx, |panel, cx| { - panel.select_next(&Default::default(), cx); - }); - assert_eq!( - render_to_strings(&panel, cx), - &[ - "+", - "Online", - " user_four", - " dir2 <=== selected", - "Offline", - " user_five", - ] - ); - - panel.update(cx, |panel, cx| { - panel.select_next(&Default::default(), cx); - }); - assert_eq!( - render_to_strings(&panel, cx), - &[ - "+", - "Online", - " user_four", - " dir2", - "Offline", - " user_five <=== selected", - ] - ); } fn render_to_strings(panel: &ViewHandle, cx: &TestAppContext) -> Vec { 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 }) diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs index 92a8b2dba7..5ec5c25914 100644 --- a/crates/theme/src/theme.rs +++ b/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, + pub section_icon_size: f32, } #[derive(Deserialize, Default, Clone, Copy)] diff --git a/styles/src/styleTree/contactsPanel.ts b/styles/src/styleTree/contactsPanel.ts index 5135e8ba45..a2caafadec 100644 --- a/styles/src/styleTree/contactsPanel.ts +++ b/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 },