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

Also, allow joining projects using the keyboard.
This commit is contained in:
Max Brunsfeld 2022-05-11 17:28:35 -07:00
parent 615319b2ab
commit 72e7079005
11 changed files with 189 additions and 85 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -15,7 +15,7 @@ use serde::Deserialize;
use settings::Settings; use settings::Settings;
use std::sync::Arc; use std::sync::Arc;
use theme::IconButton; use theme::IconButton;
use workspace::menu::{SelectNext, SelectPrev}; use workspace::menu::{Confirm, SelectNext, SelectPrev};
use workspace::{AppState, JoinProject}; use workspace::{AppState, JoinProject};
impl_actions!( impl_actions!(
@ -23,9 +23,16 @@ impl_actions!(
[RequestContact, RemoveContact, RespondToContactRequest] [RequestContact, RemoveContact, RespondToContactRequest]
); );
#[derive(Clone, Copy, PartialEq, Eq, Debug, PartialOrd, Ord)]
enum Section {
Requests,
Online,
Offline,
}
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
enum ContactEntry { enum ContactEntry {
Header(&'static str), Header(Section),
IncomingRequest(Arc<User>), IncomingRequest(Arc<User>),
OutgoingRequest(Arc<User>), OutgoingRequest(Arc<User>),
Contact(Arc<Contact>), Contact(Arc<Contact>),
@ -38,7 +45,9 @@ pub struct ContactsPanel {
list_state: ListState, list_state: ListState,
user_store: ModelHandle<UserStore>, user_store: ModelHandle<UserStore>,
filter_editor: ViewHandle<Editor>, filter_editor: ViewHandle<Editor>,
collapsed_sections: Vec<Section>,
selection: Option<usize>, selection: Option<usize>,
app_state: Arc<AppState>,
_maintain_contacts: Subscription, _maintain_contacts: Subscription,
} }
@ -62,6 +71,7 @@ pub fn init(cx: &mut MutableAppContext) {
cx.add_action(ContactsPanel::clear_filter); cx.add_action(ContactsPanel::clear_filter);
cx.add_action(ContactsPanel::select_next); cx.add_action(ContactsPanel::select_next);
cx.add_action(ContactsPanel::select_prev); cx.add_action(ContactsPanel::select_prev);
cx.add_action(ContactsPanel::confirm);
} }
impl ContactsPanel { impl ContactsPanel {
@ -97,18 +107,9 @@ impl ContactsPanel {
let is_selected = this.selection == Some(ix); let is_selected = this.selection == Some(ix);
match &this.entries[ix] { match &this.entries[ix] {
ContactEntry::Header(text) => { ContactEntry::Header(section) => {
let header_style = let is_collapsed = this.collapsed_sections.contains(&section);
theme.header_row.style_for(&Default::default(), is_selected); Self::render_header(*section, theme, is_selected, is_collapsed)
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::IncomingRequest(user) => Self::render_contact_request( ContactEntry::IncomingRequest(user) => Self::render_contact_request(
user.clone(), user.clone(),
@ -153,17 +154,64 @@ impl ContactsPanel {
} }
}), }),
selection: None, selection: None,
collapsed_sections: Default::default(),
entries: Default::default(), entries: Default::default(),
match_candidates: Default::default(), match_candidates: Default::default(),
filter_editor: user_query_editor, filter_editor: user_query_editor,
_maintain_contacts: cx _maintain_contacts: cx
.observe(&app_state.user_store, |this, _, cx| this.update_entries(cx)), .observe(&app_state.user_store, |this, _, cx| this.update_entries(cx)),
user_store: app_state.user_store.clone(), user_store: app_state.user_store.clone(),
app_state,
}; };
this.update_entries(cx); this.update_entries(cx);
this 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( fn render_contact(
contact: Arc<Contact>, contact: Arc<Contact>,
theme: &theme::ContactsPanel, theme: &theme::ContactsPanel,
@ -507,8 +555,10 @@ impl ContactsPanel {
} }
if !request_entries.is_empty() { if !request_entries.is_empty() {
self.entries.push(ContactEntry::Header("Requests")); self.entries.push(ContactEntry::Header(Section::Requests));
self.entries.append(&mut request_entries); if !self.collapsed_sections.contains(&Section::Requests) {
self.entries.append(&mut request_entries);
}
} }
let contacts = user_store.contacts(); let contacts = user_store.contacts();
@ -538,22 +588,27 @@ impl ContactsPanel {
.iter() .iter()
.partition::<Vec<_>, _>(|mat| contacts[mat.candidate_id].online); .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() { if !matches.is_empty() {
self.entries.push(ContactEntry::Header(name)); self.entries.push(ContactEntry::Header(section));
for mat in matches { if !self.collapsed_sections.contains(&section) {
let contact = &contacts[mat.candidate_id]; for mat in matches {
self.entries.push(ContactEntry::Contact(contact.clone())); let contact = &contacts[mat.candidate_id];
self.entries self.entries.push(ContactEntry::Contact(contact.clone()));
.extend(contact.projects.iter().enumerate().filter_map( self.entries
|(ix, project)| { .extend(contact.projects.iter().enumerate().filter_map(
if project.worktree_root_names.is_empty() { |(ix, project)| {
None if project.worktree_root_names.is_empty() {
} else { None
Some(ContactEntry::ContactProject(contact.clone(), ix)) } else {
} Some(ContactEntry::ContactProject(contact.clone(), ix))
}, }
)); },
));
}
} }
} }
} }
@ -624,6 +679,32 @@ impl ContactsPanel {
cx.notify(); cx.notify();
self.list_state.reset(self.entries.len()); 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 { fn render_icon_button(style: &IconButton, svg_path: &'static str) -> impl Element {
@ -706,9 +787,9 @@ impl View for ContactsPanel {
impl PartialEq for ContactEntry { impl PartialEq for ContactEntry {
fn eq(&self, other: &Self) -> bool { fn eq(&self, other: &Self) -> bool {
match self { match self {
ContactEntry::Header(name_1) => { ContactEntry::Header(section_1) => {
if let ContactEntry::Header(name_2) = other { if let ContactEntry::Header(section_2) = other {
return name_1 == name_2; return section_1 == section_2;
} }
} }
ContactEntry::IncomingRequest(user_1) => { ContactEntry::IncomingRequest(user_1) => {
@ -816,7 +897,7 @@ mod tests {
&[ &[
"+", "+",
"v Requests", "v Requests",
" incoming user_one <=== selected", " incoming user_one",
" outgoing user_two", " outgoing user_two",
"v Online", "v Online",
" user_four", " user_four",
@ -838,68 +919,81 @@ mod tests {
render_to_strings(&panel, cx), 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", " user_four <=== selected",
" dir2", " dir2",
"Offline", "v Offline",
" user_five", " 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<ContactsPanel>, cx: &TestAppContext) -> Vec<String> { fn render_to_strings(panel: &ViewHandle<ContactsPanel>, cx: &TestAppContext) -> Vec<String> {
panel.read_with(cx, |panel, _| { panel.read_with(cx, |panel, _| {
let mut entries = Vec::new(); let mut entries = Vec::new();
entries.push("+".to_string()); entries.push("+".to_string());
entries.extend(panel.entries.iter().map(|entry| match entry { entries.extend(panel.entries.iter().enumerate().map(|(ix, entry)| {
ContactEntry::Header(name) => { let mut string = match entry {
format!("{}", name) ContactEntry::Header(name) => {
} let icon = if panel.collapsed_sections.contains(name) {
ContactEntry::IncomingRequest(user) => { ">"
format!(" incoming {}", user.github_login) } else {
} "v"
ContactEntry::OutgoingRequest(user) => { };
format!(" outgoing {}", user.github_login) format!("{} {:?}", icon, name)
} }
ContactEntry::Contact(contact) => { ContactEntry::IncomingRequest(user) => {
format!(" {}", contact.user.github_login) format!(" incoming {}", user.github_login)
} }
ContactEntry::ContactProject(contact, project_ix) => { ContactEntry::OutgoingRequest(user) => {
format!( format!(" outgoing {}", user.github_login)
" {}", }
contact.projects[*project_ix].worktree_root_names.join(", ") 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 entries
}) })

View file

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

View file

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