Rework collab panel rendering to use gpui::list (#3678)

This PR reworks the rendering of the collab panel to use `gpui::list`,
so that we don't render any items that are not visible on the screen.

In the process we also fixed some bugs in the channel list:

- Fixed the context menu for channels not deploying when activated via
keyboard
- Fixed drag and drop for channels
- Made it so when navigating the collab panel via keyboard the list only
scrolls enough to reveal the next item when navigating to an item that
is currently off-screen

Release Notes:

- N/A
This commit is contained in:
Marshall Bowers 2023-12-15 14:11:24 -05:00 committed by GitHub
commit 487edcb12b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
2 changed files with 180 additions and 249 deletions

View file

@ -165,7 +165,7 @@ struct ChannelMoveClipboard {
const COLLABORATION_PANEL_KEY: &'static str = "CollaborationPanel";
use std::{iter::once, mem, sync::Arc};
use std::{mem, sync::Arc};
use call::ActiveCall;
use channel::{Channel, ChannelEvent, ChannelId, ChannelStore};
@ -175,12 +175,12 @@ use editor::Editor;
use feature_flags::{ChannelsAlpha, FeatureFlagAppExt, FeatureFlagViewExt};
use fuzzy::{match_strings, StringMatchCandidate};
use gpui::{
actions, canvas, div, fill, img, impl_actions, overlay, point, prelude::*, px, rems,
serde_json, size, Action, AnyElement, AppContext, AsyncWindowContext, Bounds, ClipboardItem,
DismissEvent, Div, EventEmitter, FocusHandle, Focusable, FocusableView, Hsla,
InteractiveElement, IntoElement, Length, Model, MouseDownEvent, ParentElement, Pixels, Point,
PromptLevel, Quad, Render, RenderOnce, ScrollHandle, SharedString, Size, Stateful, Styled,
Subscription, Task, View, ViewContext, VisualContext, WeakView,
actions, canvas, div, fill, impl_actions, list, overlay, point, prelude::*, px, serde_json,
AnyElement, AppContext, AsyncWindowContext, Bounds, ClipboardItem, DismissEvent, Div,
EventEmitter, FocusHandle, Focusable, FocusableView, InteractiveElement, IntoElement,
ListOffset, ListState, Model, MouseDownEvent, ParentElement, Pixels, Point, PromptLevel,
Render, RenderOnce, SharedString, Styled, Subscription, Task, View, ViewContext, VisualContext,
WeakView,
};
use project::{Fs, Project};
use serde_derive::{Deserialize, Serialize};
@ -188,7 +188,7 @@ use settings::{Settings, SettingsStore};
use ui::prelude::*;
use ui::{
h_stack, v_stack, Avatar, Button, Color, ContextMenu, Icon, IconButton, IconElement, IconSize,
Label, List, ListHeader, ListItem, Tooltip,
Label, ListHeader, ListItem, Tooltip,
};
use util::{maybe, ResultExt, TryFutureExt};
use workspace::{
@ -303,6 +303,7 @@ pub struct CollabPanel {
channel_clipboard: Option<ChannelMoveClipboard>,
pending_serialization: Task<Option<()>>,
context_menu: Option<(View<ContextMenu>, Point<Pixels>, Subscription)>,
list_state: ListState,
filter_editor: View<Editor>,
channel_name_editor: View<Editor>,
channel_editing_state: Option<ChannelEditingState>,
@ -313,7 +314,6 @@ pub struct CollabPanel {
client: Arc<Client>,
project: Model<Project>,
match_candidates: Vec<StringMatchCandidate>,
scroll_handle: ScrollHandle,
subscriptions: Vec<Subscription>,
collapsed_sections: Vec<Section>,
collapsed_channels: Vec<ChannelId>,
@ -398,7 +398,7 @@ enum ListEntry {
impl CollabPanel {
pub fn new(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) -> View<Self> {
cx.build_view(|cx| {
// let view_id = cx.view_id();
let view = cx.view().clone();
let filter_editor = cx.build_view(|cx| {
let mut editor = Editor::single_line(cx);
@ -445,136 +445,10 @@ impl CollabPanel {
})
.detach();
// let list_state =
// ListState::<Self>::new(0, Orientation::Top, 1000., move |this, ix, cx| {
// let theme = theme::current(cx).clone();
// let is_selected = this.selection == Some(ix);
// let current_project_id = this.project.read(cx).remote_id();
// match &this.entries[ix] {
// ListEntry::Header(section) => {
// let is_collapsed = this.collapsed_sections.contains(section);
// this.render_header(*section, &theme, is_selected, is_collapsed, cx)
// }
// ListEntry::CallParticipant {
// user,
// peer_id,
// is_pending,
// } => Self::render_call_participant(
// user,
// *peer_id,
// this.user_store.clone(),
// *is_pending,
// is_selected,
// &theme,
// cx,
// ),
// ListEntry::ParticipantProject {
// project_id,
// worktree_root_names,
// host_user_id,
// is_last,
// } => Self::render_participant_project(
// *project_id,
// worktree_root_names,
// *host_user_id,
// Some(*project_id) == current_project_id,
// *is_last,
// is_selected,
// &theme,
// cx,
// ),
// ListEntry::ParticipantScreen { peer_id, is_last } => {
// Self::render_participant_screen(
// *peer_id,
// *is_last,
// is_selected,
// &theme.collab_panel,
// cx,
// )
// }
// ListEntry::Channel {
// channel,
// depth,
// has_children,
// } => {
// let channel_row = this.render_channel(
// &*channel,
// *depth,
// &theme,
// is_selected,
// *has_children,
// ix,
// cx,
// );
// if is_selected && this.context_menu_on_selected {
// Stack::new()
// .with_child(channel_row)
// .with_child(
// ChildView::new(&this.context_menu, cx)
// .aligned()
// .bottom()
// .right(),
// )
// .into_any()
// } else {
// return channel_row;
// }
// }
// ListEntry::ChannelNotes { channel_id } => this.render_channel_notes(
// *channel_id,
// &theme.collab_panel,
// is_selected,
// ix,
// cx,
// ),
// ListEntry::ChannelChat { channel_id } => this.render_channel_chat(
// *channel_id,
// &theme.collab_panel,
// is_selected,
// ix,
// cx,
// ),
// ListEntry::ChannelInvite(channel) => Self::render_channel_invite(
// channel.clone(),
// this.channel_store.clone(),
// &theme.collab_panel,
// is_selected,
// cx,
// ),
// ListEntry::IncomingRequest(user) => Self::render_contact_request(
// user.clone(),
// this.user_store.clone(),
// &theme.collab_panel,
// true,
// is_selected,
// cx,
// ),
// ListEntry::OutgoingRequest(user) => Self::render_contact_request(
// user.clone(),
// this.user_store.clone(),
// &theme.collab_panel,
// false,
// is_selected,
// cx,
// ),
// ListEntry::Contact { contact, calling } => Self::render_contact(
// contact,
// *calling,
// &this.project,
// &theme,
// is_selected,
// cx,
// ),
// ListEntry::ChannelEditor { depth } => {
// this.render_channel_editor(&theme, *depth, cx)
// }
// ListEntry::ContactPlaceholder => {
// this.render_contact_placeholder(&theme.collab_panel, is_selected, cx)
// }
// }
// });
let list_state =
ListState::new(0, gpui::ListAlignment::Top, px(1000.), move |ix, cx| {
view.update(cx, |view, cx| view.render_list_entry(ix, cx))
});
let mut this = Self {
width: None,
@ -583,6 +457,7 @@ impl CollabPanel {
fs: workspace.app_state().fs.clone(),
pending_serialization: Task::ready(None),
context_menu: None,
list_state,
channel_name_editor,
filter_editor,
entries: Vec::default(),
@ -593,7 +468,6 @@ impl CollabPanel {
project: workspace.project().clone(),
subscriptions: Vec::default(),
match_candidates: Vec::default(),
scroll_handle: ScrollHandle::new(),
collapsed_sections: vec![Section::Offline],
collapsed_channels: Vec::default(),
workspace: workspace.weak_handle(),
@ -709,6 +583,10 @@ impl CollabPanel {
);
}
fn scroll_to_item(&mut self, ix: usize) {
self.list_state.scroll_to_reveal_item(ix)
}
fn update_entries(&mut self, select_same_item: bool, cx: &mut ViewContext<Self>) {
let channel_store = self.channel_store.read(cx);
let user_store = self.user_store.read(cx);
@ -1084,13 +962,15 @@ impl CollabPanel {
self.entries.push(ListEntry::ContactPlaceholder);
}
self.list_state.reset(self.entries.len());
if select_same_item {
if let Some(prev_selected_entry) = prev_selected_entry {
self.selection.take();
for (ix, entry) in self.entries.iter().enumerate() {
if *entry == prev_selected_entry {
self.selection = Some(ix);
self.scroll_handle.scroll_to_item(ix);
self.scroll_to_item(ix);
break;
}
}
@ -1101,16 +981,19 @@ impl CollabPanel {
None
} else {
let ix = prev_selection.min(self.entries.len() - 1);
self.scroll_handle.scroll_to_item(ix);
self.scroll_to_item(ix);
Some(ix)
}
});
}
if scroll_to_top {
self.scroll_handle.scroll_to_item(0)
self.scroll_to_item(0)
} else {
let (old_index, old_offset) = self.scroll_handle.logical_scroll_top();
let ListOffset {
item_ix: old_index,
offset_in_item: old_offset,
} = self.list_state.logical_scroll_top();
// Attempt to maintain the same scroll position.
if let Some(old_top_entry) = old_entries.get(old_index) {
let (new_index, new_offset) = self
@ -1136,8 +1019,10 @@ impl CollabPanel {
})
.unwrap_or_else(|| (old_index, old_offset));
self.scroll_handle
.set_logical_scroll_top(new_index, new_offset);
self.list_state.scroll_to(ListOffset {
item_ix: new_index,
offset_in_item: new_offset,
});
}
}
@ -1628,7 +1513,7 @@ impl CollabPanel {
}
if let Some(ix) = self.selection {
self.scroll_handle.scroll_to_item(ix)
self.scroll_to_item(ix)
}
cx.notify();
}
@ -1640,7 +1525,7 @@ impl CollabPanel {
}
if let Some(ix) = self.selection {
self.scroll_handle.scroll_to_item(ix)
self.scroll_to_item(ix)
}
cx.notify();
}
@ -1965,7 +1850,7 @@ impl CollabPanel {
};
let Some(bounds) = self
.selection
.and_then(|ix| self.scroll_handle.bounds_for_item(ix))
.and_then(|ix| self.list_state.bounds_for_item(ix))
else {
return;
};
@ -2158,78 +2043,75 @@ impl CollabPanel {
)
}
fn render_list_entry(&mut self, ix: usize, cx: &mut ViewContext<Self>) -> AnyElement {
let entry = &self.entries[ix];
let is_selected = self.selection == Some(ix);
match entry {
ListEntry::Header(section) => {
let is_collapsed = self.collapsed_sections.contains(section);
self.render_header(*section, is_selected, is_collapsed, cx)
.into_any_element()
}
ListEntry::Contact { contact, calling } => self
.render_contact(contact, *calling, is_selected, cx)
.into_any_element(),
ListEntry::ContactPlaceholder => self
.render_contact_placeholder(is_selected, cx)
.into_any_element(),
ListEntry::IncomingRequest(user) => self
.render_contact_request(user, true, is_selected, cx)
.into_any_element(),
ListEntry::OutgoingRequest(user) => self
.render_contact_request(user, false, is_selected, cx)
.into_any_element(),
ListEntry::Channel {
channel,
depth,
has_children,
} => self
.render_channel(channel, *depth, *has_children, is_selected, ix, cx)
.into_any_element(),
ListEntry::ChannelEditor { depth } => {
self.render_channel_editor(*depth, cx).into_any_element()
}
ListEntry::CallParticipant {
user,
peer_id,
is_pending,
} => self
.render_call_participant(user, *peer_id, *is_pending, cx)
.into_any_element(),
ListEntry::ParticipantProject {
project_id,
worktree_root_names,
host_user_id,
is_last,
} => self
.render_participant_project(
*project_id,
&worktree_root_names,
*host_user_id,
*is_last,
cx,
)
.into_any_element(),
ListEntry::ParticipantScreen { peer_id, is_last } => self
.render_participant_screen(*peer_id, *is_last, cx)
.into_any_element(),
ListEntry::ChannelNotes { channel_id } => self
.render_channel_notes(*channel_id, cx)
.into_any_element(),
ListEntry::ChannelChat { channel_id } => {
self.render_channel_chat(*channel_id, cx).into_any_element()
}
}
}
fn render_signed_in(&mut self, cx: &mut ViewContext<Self>) -> Div {
v_stack()
.size_full()
.child(
v_stack()
.size_full()
.id("scroll")
.overflow_y_scroll()
.track_scroll(&self.scroll_handle)
.children(self.entries.iter().enumerate().map(|(ix, entry)| {
let is_selected = self.selection == Some(ix);
match entry {
ListEntry::Header(section) => {
let is_collapsed = self.collapsed_sections.contains(section);
self.render_header(*section, is_selected, is_collapsed, cx)
.into_any_element()
}
ListEntry::Contact { contact, calling } => self
.render_contact(contact, *calling, is_selected, cx)
.into_any_element(),
ListEntry::ContactPlaceholder => self
.render_contact_placeholder(is_selected, cx)
.into_any_element(),
ListEntry::IncomingRequest(user) => self
.render_contact_request(user, true, is_selected, cx)
.into_any_element(),
ListEntry::OutgoingRequest(user) => self
.render_contact_request(user, false, is_selected, cx)
.into_any_element(),
ListEntry::Channel {
channel,
depth,
has_children,
} => self
.render_channel(channel, *depth, *has_children, is_selected, ix, cx)
.into_any_element(),
ListEntry::ChannelEditor { depth } => {
self.render_channel_editor(*depth, cx).into_any_element()
}
ListEntry::CallParticipant {
user,
peer_id,
is_pending,
} => self
.render_call_participant(user, *peer_id, *is_pending, cx)
.into_any_element(),
ListEntry::ParticipantProject {
project_id,
worktree_root_names,
host_user_id,
is_last,
} => self
.render_participant_project(
*project_id,
&worktree_root_names,
*host_user_id,
*is_last,
cx,
)
.into_any_element(),
ListEntry::ParticipantScreen { peer_id, is_last } => self
.render_participant_screen(*peer_id, *is_last, cx)
.into_any_element(),
ListEntry::ChannelNotes { channel_id } => self
.render_channel_notes(*channel_id, cx)
.into_any_element(),
ListEntry::ChannelChat { channel_id } => {
self.render_channel_chat(*channel_id, cx).into_any_element()
}
}
})),
)
.child(list(self.list_state.clone()).full())
.child(
div().p_2().child(
div()
@ -2343,18 +2225,14 @@ impl CollabPanel {
.selected(is_selected),
)
.when(section == Section::Channels, |el| {
el.drag_over::<DraggedChannelView>(|style| {
style.bg(cx.theme().colors().ghost_element_hover)
})
.on_drop(cx.listener(
move |this, view: &View<DraggedChannelView>, cx| {
el.drag_over::<Channel>(|style| style.bg(cx.theme().colors().ghost_element_hover))
.on_drop(cx.listener(move |this, dragged_channel: &Channel, cx| {
this.channel_store
.update(cx, |channel_store, cx| {
channel_store.move_channel(view.read(cx).channel.id, None, cx)
channel_store.move_channel(dragged_channel.id, None, cx)
})
.detach_and_log_err(cx)
},
))
}))
});
if section == Section::Offline {
@ -2569,22 +2447,14 @@ impl CollabPanel {
width,
})
})
.drag_over::<DraggedChannelView>(|style| {
style.bg(cx.theme().colors().ghost_element_hover)
})
.on_drop(
cx.listener(move |this, view: &View<DraggedChannelView>, cx| {
this.channel_store
.update(cx, |channel_store, cx| {
channel_store.move_channel(
view.read(cx).channel.id,
Some(channel_id),
cx,
)
})
.detach_and_log_err(cx)
}),
)
.drag_over::<Channel>(|style| style.bg(cx.theme().colors().ghost_element_hover))
.on_drop(cx.listener(move |this, dragged_channel: &Channel, cx| {
this.channel_store
.update(cx, |channel_store, cx| {
channel_store.move_channel(dragged_channel.id, Some(channel_id), cx)
})
.detach_and_log_err(cx)
}))
.child(
ListItem::new(channel_id as usize)
// Offset the indent depth by one to give us room to show the disclosure.