Files
oak-gpui/crates/collab_ui/src/collab_panel.rs
T
Marshall Bowers 8cb291baa4 Take a different approach to rendering channel buttons (#3991)
This PR changes the approach we're using to render the channel buttons
to use a more straightforward (and less hacky) approach.

### Motivation

Even with the variety of hacks that were employed to make the current
approach work, there are still a number of issues with the current
solution:

- Hovering in the empty space to the left of a channel doesn't correctly
apply the hover background to the container for the channel buttons
- Hovering to the very right of the collab panel (right on top of the
drag handle) causes the channel button container to apply its hover
background without applying it to the rest of the row
- The buttons would still get pushed off to the right by the indent
space in the channel tree, resulting in jagged indicators at small sizes

Additionally, the rectangular background placed behind the channel
buttons still didn't look great when it overlapped with the channel
names.

### Explanation

For these reasons, I decided to explore a simpler approach that
addresses these issues, albeit with some tradeoffs that I think are
acceptable.

We now render the absolutely-positioned button container as a sibling
element of the `ListItem`. This is to avoid issues with the container
getting pushed around based on the contents of the `ListItem` rather
than staying absolutely positioned at the end of the row.

We also have gotten rid of the background for the button container, and
now rely on the background of the individual `IconButton`s to occlude
the channel name behind them when the two are overlapping.

Here are some examples of the new UI in various configurations:

#### When the channel entry is hovered

<img width="270" alt="Screenshot 2024-01-09 at 6 15 24 PM"
src="https://github.com/zed-industries/zed/assets/1486634/0207a129-30eb-4067-8490-3b6c9cbf75ea">

#### Overlapping with the channel name

<img width="229" alt="Screenshot 2024-01-09 at 6 15 43 PM"
src="https://github.com/zed-industries/zed/assets/1486634/0a67507e-45c8-4325-a71e-d416edc3a115">

#### Narrow collab panel

<img width="116" alt="Screenshot 2024-01-09 at 6 16 07 PM"
src="https://github.com/zed-industries/zed/assets/1486634/887ebaa3-e4d6-4497-9141-afcc8b7cd356">

### Tradeoffs

The new approach comes with the following tradeoffs that I am currently
aware of:

The occlusion can look a little weird when the icons are in the middle
of a channel name (as opposed to fully occluding the end of the channel
name):
<img width="190" alt="Screenshot 2024-01-09 at 6 24 32 PM"
src="https://github.com/zed-industries/zed/assets/1486634/0062b806-1b8f-47eb-af8d-f061d9829366">

Hovering one of the icons causes the icon to be hovered instead of the
row:

<img width="232" alt="Screenshot 2024-01-09 at 6 31 38 PM"
src="https://github.com/zed-industries/zed/assets/1486634/cbcc1a45-64d2-4890-8ad6-c5a5ee961b86">

Release Notes:

- Improved the way channel buttons are displayed.
2024-01-09 18:39:29 -05:00

2625 lines
97 KiB
Rust

mod channel_modal;
mod contact_finder;
use self::channel_modal::ChannelModal;
use crate::{
channel_view::ChannelView, chat_panel::ChatPanel, face_pile::FacePile,
CollaborationPanelSettings,
};
use call::ActiveCall;
use channel::{Channel, ChannelEvent, ChannelId, ChannelStore};
use client::{Client, Contact, User, UserStore};
use contact_finder::ContactFinder;
use db::kvp::KEY_VALUE_STORE;
use editor::{Editor, EditorElement, EditorStyle};
use feature_flags::{ChannelsAlpha, FeatureFlagAppExt, FeatureFlagViewExt};
use fuzzy::{match_strings, StringMatchCandidate};
use gpui::{
actions, canvas, div, fill, list, overlay, point, prelude::*, px, AnyElement, AppContext,
AsyncWindowContext, Bounds, ClipboardItem, DismissEvent, Div, EventEmitter, FocusHandle,
FocusableView, FontStyle, FontWeight, InteractiveElement, IntoElement, ListOffset, ListState,
Model, MouseDownEvent, ParentElement, Pixels, Point, PromptLevel, Render, RenderOnce,
SharedString, Styled, Subscription, Task, TextStyle, View, ViewContext, VisualContext,
WeakView, WhiteSpace,
};
use menu::{Cancel, Confirm, SelectNext, SelectPrev};
use project::{Fs, Project};
use rpc::proto::{self, PeerId};
use serde_derive::{Deserialize, Serialize};
use settings::{Settings, SettingsStore};
use smallvec::SmallVec;
use std::{mem, sync::Arc};
use theme::{ActiveTheme, ThemeSettings};
use ui::{
prelude::*, Avatar, Button, Color, ContextMenu, Icon, IconButton, IconName, IconSize, Label,
ListHeader, ListItem, Tooltip,
};
use util::{maybe, ResultExt, TryFutureExt};
use workspace::{
dock::{DockPosition, Panel, PanelEvent},
notifications::NotifyResultExt,
Workspace,
};
actions!(
collab_panel,
[
ToggleFocus,
Remove,
Secondary,
CollapseSelectedChannel,
ExpandSelectedChannel,
StartMoveChannel,
MoveSelected,
InsertSpace,
]
);
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
struct ChannelMoveClipboard {
channel_id: ChannelId,
}
const COLLABORATION_PANEL_KEY: &'static str = "CollaborationPanel";
pub fn init(cx: &mut AppContext) {
cx.observe_new_views(|workspace: &mut Workspace, _| {
workspace.register_action(|workspace, _: &ToggleFocus, cx| {
workspace.toggle_panel_focus::<CollabPanel>(cx);
});
})
.detach();
}
#[derive(Debug)]
pub enum ChannelEditingState {
Create {
location: Option<ChannelId>,
pending_name: Option<String>,
},
Rename {
location: ChannelId,
pending_name: Option<String>,
},
}
impl ChannelEditingState {
fn pending_name(&self) -> Option<String> {
match self {
ChannelEditingState::Create { pending_name, .. } => pending_name.clone(),
ChannelEditingState::Rename { pending_name, .. } => pending_name.clone(),
}
}
}
pub struct CollabPanel {
width: Option<Pixels>,
fs: Arc<dyn Fs>,
focus_handle: FocusHandle,
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>,
entries: Vec<ListEntry>,
selection: Option<usize>,
channel_store: Model<ChannelStore>,
user_store: Model<UserStore>,
client: Arc<Client>,
project: Model<Project>,
match_candidates: Vec<StringMatchCandidate>,
subscriptions: Vec<Subscription>,
collapsed_sections: Vec<Section>,
collapsed_channels: Vec<ChannelId>,
workspace: WeakView<Workspace>,
}
#[derive(Serialize, Deserialize)]
struct SerializedCollabPanel {
width: Option<Pixels>,
collapsed_channels: Option<Vec<u64>>,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug, PartialOrd, Ord)]
enum Section {
ActiveCall,
Channels,
ChannelInvites,
ContactRequests,
Contacts,
Online,
Offline,
}
#[derive(Clone, Debug)]
enum ListEntry {
Header(Section),
CallParticipant {
user: Arc<User>,
peer_id: Option<PeerId>,
is_pending: bool,
},
ParticipantProject {
project_id: u64,
worktree_root_names: Vec<String>,
host_user_id: u64,
is_last: bool,
},
ParticipantScreen {
peer_id: Option<PeerId>,
is_last: bool,
},
GuestCount {
count: usize,
has_visible_participants: bool,
},
IncomingRequest(Arc<User>),
OutgoingRequest(Arc<User>),
ChannelInvite(Arc<Channel>),
Channel {
channel: Arc<Channel>,
depth: usize,
has_children: bool,
},
ChannelNotes {
channel_id: ChannelId,
},
ChannelChat {
channel_id: ChannelId,
},
ChannelEditor {
depth: usize,
},
Contact {
contact: Arc<Contact>,
calling: bool,
},
ContactPlaceholder,
}
impl CollabPanel {
pub fn new(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) -> View<Self> {
cx.new_view(|cx| {
let filter_editor = cx.new_view(|cx| {
let mut editor = Editor::single_line(cx);
editor.set_placeholder_text("Filter...", cx);
editor
});
cx.subscribe(&filter_editor, |this: &mut Self, _, event, cx| {
if let editor::EditorEvent::BufferEdited = event {
let query = this.filter_editor.read(cx).text(cx);
if !query.is_empty() {
this.selection.take();
}
this.update_entries(true, cx);
if !query.is_empty() {
this.selection = this
.entries
.iter()
.position(|entry| !matches!(entry, ListEntry::Header(_)));
}
}
})
.detach();
let channel_name_editor = cx.new_view(|cx| Editor::single_line(cx));
cx.subscribe(&channel_name_editor, |this: &mut Self, _, event, cx| {
if let editor::EditorEvent::Blurred = event {
if let Some(state) = &this.channel_editing_state {
if state.pending_name().is_some() {
return;
}
}
this.take_editing_state(cx);
this.update_entries(false, cx);
cx.notify();
}
})
.detach();
let view = cx.view().downgrade();
let list_state =
ListState::new(0, gpui::ListAlignment::Top, px(1000.), move |ix, cx| {
if let Some(view) = view.upgrade() {
view.update(cx, |view, cx| view.render_list_entry(ix, cx))
} else {
div().into_any()
}
});
let mut this = Self {
width: None,
focus_handle: cx.focus_handle(),
channel_clipboard: None,
fs: workspace.app_state().fs.clone(),
pending_serialization: Task::ready(None),
context_menu: None,
list_state,
channel_name_editor,
filter_editor,
entries: Vec::default(),
channel_editing_state: None,
selection: None,
channel_store: ChannelStore::global(cx),
user_store: workspace.user_store().clone(),
project: workspace.project().clone(),
subscriptions: Vec::default(),
match_candidates: Vec::default(),
collapsed_sections: vec![Section::Offline],
collapsed_channels: Vec::default(),
workspace: workspace.weak_handle(),
client: workspace.app_state().client.clone(),
};
this.update_entries(false, cx);
// Update the dock position when the setting changes.
let mut old_dock_position = this.position(cx);
this.subscriptions.push(cx.observe_global::<SettingsStore>(
move |this: &mut Self, cx| {
let new_dock_position = this.position(cx);
if new_dock_position != old_dock_position {
old_dock_position = new_dock_position;
cx.emit(PanelEvent::ChangePosition);
}
cx.notify();
},
));
let active_call = ActiveCall::global(cx);
this.subscriptions
.push(cx.observe(&this.user_store, |this, _, cx| {
this.update_entries(true, cx)
}));
this.subscriptions
.push(cx.observe(&this.channel_store, |this, _, cx| {
this.update_entries(true, cx)
}));
this.subscriptions
.push(cx.observe(&active_call, |this, _, cx| this.update_entries(true, cx)));
this.subscriptions
.push(cx.observe_flag::<ChannelsAlpha, _>(move |_, this, cx| {
this.update_entries(true, cx)
}));
this.subscriptions.push(cx.subscribe(
&this.channel_store,
|this, _channel_store, e, cx| match e {
ChannelEvent::ChannelCreated(channel_id)
| ChannelEvent::ChannelRenamed(channel_id) => {
if this.take_editing_state(cx) {
this.update_entries(false, cx);
this.selection = this.entries.iter().position(|entry| {
if let ListEntry::Channel { channel, .. } = entry {
channel.id == *channel_id
} else {
false
}
});
}
}
},
));
this
})
}
pub async fn load(
workspace: WeakView<Workspace>,
mut cx: AsyncWindowContext,
) -> anyhow::Result<View<Self>> {
let serialized_panel = cx
.background_executor()
.spawn(async move { KEY_VALUE_STORE.read_kvp(COLLABORATION_PANEL_KEY) })
.await
.map_err(|_| anyhow::anyhow!("Failed to read collaboration panel from key value store"))
.log_err()
.flatten()
.map(|panel| serde_json::from_str::<SerializedCollabPanel>(&panel))
.transpose()
.log_err()
.flatten();
workspace.update(&mut cx, |workspace, cx| {
let panel = CollabPanel::new(workspace, cx);
if let Some(serialized_panel) = serialized_panel {
panel.update(cx, |panel, cx| {
panel.width = serialized_panel.width;
panel.collapsed_channels = serialized_panel
.collapsed_channels
.unwrap_or_else(|| Vec::new());
cx.notify();
});
}
panel
})
}
fn serialize(&mut self, cx: &mut ViewContext<Self>) {
let width = self.width;
let collapsed_channels = self.collapsed_channels.clone();
self.pending_serialization = cx.background_executor().spawn(
async move {
KEY_VALUE_STORE
.write_kvp(
COLLABORATION_PANEL_KEY.into(),
serde_json::to_string(&SerializedCollabPanel {
width,
collapsed_channels: Some(collapsed_channels),
})?,
)
.await?;
anyhow::Ok(())
}
.log_err(),
);
}
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);
let query = self.filter_editor.read(cx).text(cx);
let executor = cx.background_executor().clone();
let prev_selected_entry = self.selection.and_then(|ix| self.entries.get(ix).cloned());
let old_entries = mem::take(&mut self.entries);
let mut scroll_to_top = false;
if let Some(room) = ActiveCall::global(cx).read(cx).room() {
self.entries.push(ListEntry::Header(Section::ActiveCall));
if !old_entries
.iter()
.any(|entry| matches!(entry, ListEntry::Header(Section::ActiveCall)))
{
scroll_to_top = true;
}
if !self.collapsed_sections.contains(&Section::ActiveCall) {
let room = room.read(cx);
let mut guest_count_ix = 0;
let mut guest_count = if room.read_only() { 1 } else { 0 };
let mut non_guest_count = if room.read_only() { 0 } else { 1 };
if let Some(channel_id) = room.channel_id() {
self.entries.push(ListEntry::ChannelNotes { channel_id });
self.entries.push(ListEntry::ChannelChat { channel_id });
guest_count_ix = self.entries.len();
}
// Populate the active user.
if let Some(user) = user_store.current_user() {
self.match_candidates.clear();
self.match_candidates.push(StringMatchCandidate {
id: 0,
string: user.github_login.clone(),
char_bag: user.github_login.chars().collect(),
});
let matches = executor.block(match_strings(
&self.match_candidates,
&query,
true,
usize::MAX,
&Default::default(),
executor.clone(),
));
if !matches.is_empty() && !room.read_only() {
let user_id = user.id;
self.entries.push(ListEntry::CallParticipant {
user,
peer_id: None,
is_pending: false,
});
let mut projects = room.local_participant().projects.iter().peekable();
while let Some(project) = projects.next() {
self.entries.push(ListEntry::ParticipantProject {
project_id: project.id,
worktree_root_names: project.worktree_root_names.clone(),
host_user_id: user_id,
is_last: projects.peek().is_none() && !room.is_screen_sharing(),
});
}
if room.is_screen_sharing() {
self.entries.push(ListEntry::ParticipantScreen {
peer_id: None,
is_last: true,
});
}
}
}
// Populate remote participants.
self.match_candidates.clear();
self.match_candidates
.extend(
room.remote_participants()
.iter()
.filter_map(|(_, participant)| {
if participant.role == proto::ChannelRole::Guest {
guest_count += 1;
return None;
} else {
non_guest_count += 1;
}
Some(StringMatchCandidate {
id: participant.user.id as usize,
string: participant.user.github_login.clone(),
char_bag: participant.user.github_login.chars().collect(),
})
}),
);
let matches = executor.block(match_strings(
&self.match_candidates,
&query,
true,
usize::MAX,
&Default::default(),
executor.clone(),
));
for mat in matches {
let user_id = mat.candidate_id as u64;
let participant = &room.remote_participants()[&user_id];
self.entries.push(ListEntry::CallParticipant {
user: participant.user.clone(),
peer_id: Some(participant.peer_id),
is_pending: false,
});
let mut projects = participant.projects.iter().peekable();
while let Some(project) = projects.next() {
self.entries.push(ListEntry::ParticipantProject {
project_id: project.id,
worktree_root_names: project.worktree_root_names.clone(),
host_user_id: participant.user.id,
is_last: projects.peek().is_none()
&& participant.video_tracks.is_empty(),
});
}
if !participant.video_tracks.is_empty() {
self.entries.push(ListEntry::ParticipantScreen {
peer_id: Some(participant.peer_id),
is_last: true,
});
}
}
if guest_count > 0 {
self.entries.insert(
guest_count_ix,
ListEntry::GuestCount {
count: guest_count,
has_visible_participants: non_guest_count > 0,
},
);
}
// Populate pending participants.
self.match_candidates.clear();
self.match_candidates
.extend(room.pending_participants().iter().enumerate().map(
|(id, participant)| StringMatchCandidate {
id,
string: participant.github_login.clone(),
char_bag: participant.github_login.chars().collect(),
},
));
let matches = executor.block(match_strings(
&self.match_candidates,
&query,
true,
usize::MAX,
&Default::default(),
executor.clone(),
));
self.entries
.extend(matches.iter().map(|mat| ListEntry::CallParticipant {
user: room.pending_participants()[mat.candidate_id].clone(),
peer_id: None,
is_pending: true,
}));
}
}
let mut request_entries = Vec::new();
if cx.has_flag::<ChannelsAlpha>() {
self.entries.push(ListEntry::Header(Section::Channels));
if channel_store.channel_count() > 0 || self.channel_editing_state.is_some() {
self.match_candidates.clear();
self.match_candidates
.extend(channel_store.ordered_channels().enumerate().map(
|(ix, (_, channel))| StringMatchCandidate {
id: ix,
string: channel.name.clone().into(),
char_bag: channel.name.chars().collect(),
},
));
let matches = executor.block(match_strings(
&self.match_candidates,
&query,
true,
usize::MAX,
&Default::default(),
executor.clone(),
));
if let Some(state) = &self.channel_editing_state {
if matches!(state, ChannelEditingState::Create { location: None, .. }) {
self.entries.push(ListEntry::ChannelEditor { depth: 0 });
}
}
let mut collapse_depth = None;
for mat in matches {
let channel = channel_store.channel_at_index(mat.candidate_id).unwrap();
let depth = channel.parent_path.len();
if collapse_depth.is_none() && self.is_channel_collapsed(channel.id) {
collapse_depth = Some(depth);
} else if let Some(collapsed_depth) = collapse_depth {
if depth > collapsed_depth {
continue;
}
if self.is_channel_collapsed(channel.id) {
collapse_depth = Some(depth);
} else {
collapse_depth = None;
}
}
let has_children = channel_store
.channel_at_index(mat.candidate_id + 1)
.map_or(false, |next_channel| {
next_channel.parent_path.ends_with(&[channel.id])
});
match &self.channel_editing_state {
Some(ChannelEditingState::Create {
location: parent_id,
..
}) if *parent_id == Some(channel.id) => {
self.entries.push(ListEntry::Channel {
channel: channel.clone(),
depth,
has_children: false,
});
self.entries
.push(ListEntry::ChannelEditor { depth: depth + 1 });
}
Some(ChannelEditingState::Rename {
location: parent_id,
..
}) if parent_id == &channel.id => {
self.entries.push(ListEntry::ChannelEditor { depth });
}
_ => {
self.entries.push(ListEntry::Channel {
channel: channel.clone(),
depth,
has_children,
});
}
}
}
}
let channel_invites = channel_store.channel_invitations();
if !channel_invites.is_empty() {
self.match_candidates.clear();
self.match_candidates
.extend(channel_invites.iter().enumerate().map(|(ix, channel)| {
StringMatchCandidate {
id: ix,
string: channel.name.clone().into(),
char_bag: channel.name.chars().collect(),
}
}));
let matches = executor.block(match_strings(
&self.match_candidates,
&query,
true,
usize::MAX,
&Default::default(),
executor.clone(),
));
request_entries.extend(matches.iter().map(|mat| {
ListEntry::ChannelInvite(channel_invites[mat.candidate_id].clone())
}));
if !request_entries.is_empty() {
self.entries
.push(ListEntry::Header(Section::ChannelInvites));
if !self.collapsed_sections.contains(&Section::ChannelInvites) {
self.entries.append(&mut request_entries);
}
}
}
}
self.entries.push(ListEntry::Header(Section::Contacts));
request_entries.clear();
let incoming = user_store.incoming_contact_requests();
if !incoming.is_empty() {
self.match_candidates.clear();
self.match_candidates
.extend(
incoming
.iter()
.enumerate()
.map(|(ix, user)| StringMatchCandidate {
id: ix,
string: user.github_login.clone(),
char_bag: user.github_login.chars().collect(),
}),
);
let matches = executor.block(match_strings(
&self.match_candidates,
&query,
true,
usize::MAX,
&Default::default(),
executor.clone(),
));
request_entries.extend(
matches
.iter()
.map(|mat| ListEntry::IncomingRequest(incoming[mat.candidate_id].clone())),
);
}
let outgoing = user_store.outgoing_contact_requests();
if !outgoing.is_empty() {
self.match_candidates.clear();
self.match_candidates
.extend(
outgoing
.iter()
.enumerate()
.map(|(ix, user)| StringMatchCandidate {
id: ix,
string: user.github_login.clone(),
char_bag: user.github_login.chars().collect(),
}),
);
let matches = executor.block(match_strings(
&self.match_candidates,
&query,
true,
usize::MAX,
&Default::default(),
executor.clone(),
));
request_entries.extend(
matches
.iter()
.map(|mat| ListEntry::OutgoingRequest(outgoing[mat.candidate_id].clone())),
);
}
if !request_entries.is_empty() {
self.entries
.push(ListEntry::Header(Section::ContactRequests));
if !self.collapsed_sections.contains(&Section::ContactRequests) {
self.entries.append(&mut request_entries);
}
}
let contacts = user_store.contacts();
if !contacts.is_empty() {
self.match_candidates.clear();
self.match_candidates
.extend(
contacts
.iter()
.enumerate()
.map(|(ix, contact)| StringMatchCandidate {
id: ix,
string: contact.user.github_login.clone(),
char_bag: contact.user.github_login.chars().collect(),
}),
);
let matches = executor.block(match_strings(
&self.match_candidates,
&query,
true,
usize::MAX,
&Default::default(),
executor.clone(),
));
let (online_contacts, offline_contacts) = matches
.iter()
.partition::<Vec<_>, _>(|mat| contacts[mat.candidate_id].online);
for (matches, section) in [
(online_contacts, Section::Online),
(offline_contacts, Section::Offline),
] {
if !matches.is_empty() {
self.entries.push(ListEntry::Header(section));
if !self.collapsed_sections.contains(&section) {
let active_call = &ActiveCall::global(cx).read(cx);
for mat in matches {
let contact = &contacts[mat.candidate_id];
self.entries.push(ListEntry::Contact {
contact: contact.clone(),
calling: active_call.pending_invites().contains(&contact.user.id),
});
}
}
}
}
}
if incoming.is_empty() && outgoing.is_empty() && contacts.is_empty() {
self.entries.push(ListEntry::ContactPlaceholder);
}
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);
break;
}
}
}
} else {
self.selection = self.selection.and_then(|prev_selection| {
if self.entries.is_empty() {
None
} else {
Some(prev_selection.min(self.entries.len() - 1))
}
});
}
let old_scroll_top = self.list_state.logical_scroll_top();
self.list_state.reset(self.entries.len());
if scroll_to_top {
self.list_state.scroll_to(ListOffset::default());
} else {
// Attempt to maintain the same scroll position.
if let Some(old_top_entry) = old_entries.get(old_scroll_top.item_ix) {
let new_scroll_top = self
.entries
.iter()
.position(|entry| entry == old_top_entry)
.map(|item_ix| ListOffset {
item_ix,
offset_in_item: old_scroll_top.offset_in_item,
})
.or_else(|| {
let entry_after_old_top = old_entries.get(old_scroll_top.item_ix + 1)?;
let item_ix = self
.entries
.iter()
.position(|entry| entry == entry_after_old_top)?;
Some(ListOffset {
item_ix,
offset_in_item: Pixels::ZERO,
})
})
.or_else(|| {
let entry_before_old_top =
old_entries.get(old_scroll_top.item_ix.saturating_sub(1))?;
let item_ix = self
.entries
.iter()
.position(|entry| entry == entry_before_old_top)?;
Some(ListOffset {
item_ix,
offset_in_item: Pixels::ZERO,
})
});
self.list_state
.scroll_to(new_scroll_top.unwrap_or(old_scroll_top));
}
}
cx.notify();
}
fn render_call_participant(
&self,
user: &Arc<User>,
peer_id: Option<PeerId>,
is_pending: bool,
is_selected: bool,
cx: &mut ViewContext<Self>,
) -> ListItem {
let is_current_user =
self.user_store.read(cx).current_user().map(|user| user.id) == Some(user.id);
let tooltip = format!("Follow {}", user.github_login);
ListItem::new(SharedString::from(user.github_login.clone()))
.start_slot(Avatar::new(user.avatar_uri.clone()))
.child(Label::new(user.github_login.clone()))
.selected(is_selected)
.end_slot(if is_pending {
Label::new("Calling").color(Color::Muted).into_any_element()
} else if is_current_user {
IconButton::new("leave-call", IconName::Exit)
.style(ButtonStyle::Subtle)
.on_click(move |_, cx| Self::leave_call(cx))
.tooltip(|cx| Tooltip::text("Leave Call", cx))
.into_any_element()
} else {
div().into_any_element()
})
.when_some(peer_id, |this, peer_id| {
this.tooltip(move |cx| Tooltip::text(tooltip.clone(), cx))
.on_click(cx.listener(move |this, _, cx| {
this.workspace
.update(cx, |workspace, cx| workspace.follow(peer_id, cx))
.ok();
}))
})
}
fn render_participant_project(
&self,
project_id: u64,
worktree_root_names: &[String],
host_user_id: u64,
is_last: bool,
is_selected: bool,
cx: &mut ViewContext<Self>,
) -> impl IntoElement {
let project_name: SharedString = if worktree_root_names.is_empty() {
"untitled".to_string()
} else {
worktree_root_names.join(", ")
}
.into();
ListItem::new(project_id as usize)
.selected(is_selected)
.on_click(cx.listener(move |this, _, cx| {
this.workspace
.update(cx, |workspace, cx| {
let app_state = workspace.app_state().clone();
workspace::join_remote_project(project_id, host_user_id, app_state, cx)
.detach_and_log_err(cx);
})
.ok();
}))
.start_slot(
h_stack()
.gap_1()
.child(render_tree_branch(is_last, false, cx))
.child(IconButton::new(0, IconName::Folder)),
)
.child(Label::new(project_name.clone()))
.tooltip(move |cx| Tooltip::text(format!("Open {}", project_name), cx))
}
fn render_participant_screen(
&self,
peer_id: Option<PeerId>,
is_last: bool,
is_selected: bool,
cx: &mut ViewContext<Self>,
) -> impl IntoElement {
let id = peer_id.map_or(usize::MAX, |id| id.as_u64() as usize);
ListItem::new(("screen", id))
.selected(is_selected)
.start_slot(
h_stack()
.gap_1()
.child(render_tree_branch(is_last, false, cx))
.child(IconButton::new(0, IconName::Screen)),
)
.child(Label::new("Screen"))
.when_some(peer_id, |this, _| {
this.on_click(cx.listener(move |this, _, cx| {
this.workspace
.update(cx, |workspace, cx| {
workspace.open_shared_screen(peer_id.unwrap(), cx)
})
.ok();
}))
.tooltip(move |cx| Tooltip::text(format!("Open shared screen"), cx))
})
}
fn take_editing_state(&mut self, cx: &mut ViewContext<Self>) -> bool {
if let Some(_) = self.channel_editing_state.take() {
self.channel_name_editor.update(cx, |editor, cx| {
editor.set_text("", cx);
});
true
} else {
false
}
}
fn render_channel_notes(
&self,
channel_id: ChannelId,
is_selected: bool,
cx: &mut ViewContext<Self>,
) -> impl IntoElement {
ListItem::new("channel-notes")
.selected(is_selected)
.on_click(cx.listener(move |this, _, cx| {
this.open_channel_notes(channel_id, cx);
}))
.start_slot(
h_stack()
.gap_1()
.child(render_tree_branch(false, true, cx))
.child(IconButton::new(0, IconName::File)),
)
.child(div().h_7().w_full().child(Label::new("notes")))
.tooltip(move |cx| Tooltip::text("Open Channel Notes", cx))
}
fn render_channel_chat(
&self,
channel_id: ChannelId,
is_selected: bool,
cx: &mut ViewContext<Self>,
) -> impl IntoElement {
ListItem::new("channel-chat")
.selected(is_selected)
.on_click(cx.listener(move |this, _, cx| {
this.join_channel_chat(channel_id, cx);
}))
.start_slot(
h_stack()
.gap_1()
.child(render_tree_branch(false, false, cx))
.child(IconButton::new(0, IconName::MessageBubbles)),
)
.child(Label::new("chat"))
.tooltip(move |cx| Tooltip::text("Open Chat", cx))
}
fn render_guest_count(
&self,
count: usize,
has_visible_participants: bool,
is_selected: bool,
cx: &mut ViewContext<Self>,
) -> impl IntoElement {
let manageable_channel_id = ActiveCall::global(cx).read(cx).room().and_then(|room| {
let room = room.read(cx);
if room.local_participant_is_admin() {
room.channel_id()
} else {
None
}
});
ListItem::new("guest_count")
.selected(is_selected)
.start_slot(
h_stack()
.gap_1()
.child(render_tree_branch(!has_visible_participants, false, cx))
.child(""),
)
.child(Label::new(if count == 1 {
format!("{} guest", count)
} else {
format!("{} guests", count)
}))
.when_some(manageable_channel_id, |el, channel_id| {
el.tooltip(move |cx| Tooltip::text("Manage Members", cx))
.on_click(cx.listener(move |this, _, cx| this.manage_members(channel_id, cx)))
})
}
fn has_subchannels(&self, ix: usize) -> bool {
self.entries.get(ix).map_or(false, |entry| {
if let ListEntry::Channel { has_children, .. } = entry {
*has_children
} else {
false
}
})
}
fn deploy_channel_context_menu(
&mut self,
position: Point<Pixels>,
channel_id: ChannelId,
ix: usize,
cx: &mut ViewContext<Self>,
) {
let clipboard_channel_name = self.channel_clipboard.as_ref().and_then(|clipboard| {
self.channel_store
.read(cx)
.channel_for_id(clipboard.channel_id)
.map(|channel| channel.name.clone())
});
let this = cx.view().clone();
let context_menu = ContextMenu::build(cx, |mut context_menu, cx| {
if self.has_subchannels(ix) {
let expand_action_name = if self.is_channel_collapsed(channel_id) {
"Expand Subchannels"
} else {
"Collapse Subchannels"
};
context_menu = context_menu.entry(
expand_action_name,
None,
cx.handler_for(&this, move |this, cx| {
this.toggle_channel_collapsed(channel_id, cx)
}),
);
}
context_menu = context_menu
.entry(
"Open Notes",
None,
cx.handler_for(&this, move |this, cx| {
this.open_channel_notes(channel_id, cx)
}),
)
.entry(
"Open Chat",
None,
cx.handler_for(&this, move |this, cx| {
this.join_channel_chat(channel_id, cx)
}),
)
.entry(
"Copy Channel Link",
None,
cx.handler_for(&this, move |this, cx| {
this.copy_channel_link(channel_id, cx)
}),
);
if self.channel_store.read(cx).is_channel_admin(channel_id) {
context_menu = context_menu
.separator()
.entry(
"New Subchannel",
None,
cx.handler_for(&this, move |this, cx| this.new_subchannel(channel_id, cx)),
)
.entry(
"Rename",
None,
cx.handler_for(&this, move |this, cx| this.rename_channel(channel_id, cx)),
)
.entry(
"Move this channel",
None,
cx.handler_for(&this, move |this, cx| {
this.start_move_channel(channel_id, cx)
}),
);
if let Some(channel_name) = clipboard_channel_name {
context_menu = context_menu.separator().entry(
format!("Move '#{}' here", channel_name),
None,
cx.handler_for(&this, move |this, cx| {
this.move_channel_on_clipboard(channel_id, cx)
}),
);
}
context_menu = context_menu
.separator()
.entry(
"Invite Members",
None,
cx.handler_for(&this, move |this, cx| this.invite_members(channel_id, cx)),
)
.entry(
"Manage Members",
None,
cx.handler_for(&this, move |this, cx| this.manage_members(channel_id, cx)),
)
.entry(
"Delete",
None,
cx.handler_for(&this, move |this, cx| this.remove_channel(channel_id, cx)),
);
}
context_menu
});
cx.focus_view(&context_menu);
let subscription =
cx.subscribe(&context_menu, |this, _, _: &DismissEvent, cx| {
if this.context_menu.as_ref().is_some_and(|context_menu| {
context_menu.0.focus_handle(cx).contains_focused(cx)
}) {
cx.focus_self();
}
this.context_menu.take();
cx.notify();
});
self.context_menu = Some((context_menu, position, subscription));
cx.notify();
}
fn cancel(&mut self, _: &Cancel, cx: &mut ViewContext<Self>) {
if self.take_editing_state(cx) {
cx.focus_view(&self.filter_editor);
} else {
self.filter_editor.update(cx, |editor, cx| {
if editor.buffer().read(cx).len(cx) > 0 {
editor.set_text("", cx);
}
});
}
self.update_entries(false, cx);
}
fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
let ix = self.selection.map_or(0, |ix| ix + 1);
if ix < self.entries.len() {
self.selection = Some(ix);
}
if let Some(ix) = self.selection {
self.scroll_to_item(ix)
}
cx.notify();
}
fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
let ix = self.selection.take().unwrap_or(0);
if ix > 0 {
self.selection = Some(ix - 1);
}
if let Some(ix) = self.selection {
self.scroll_to_item(ix)
}
cx.notify();
}
fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
if self.confirm_channel_edit(cx) {
return;
}
if let Some(selection) = self.selection {
if let Some(entry) = self.entries.get(selection) {
match entry {
ListEntry::Header(section) => match section {
Section::ActiveCall => Self::leave_call(cx),
Section::Channels => self.new_root_channel(cx),
Section::Contacts => self.toggle_contact_finder(cx),
Section::ContactRequests
| Section::Online
| Section::Offline
| Section::ChannelInvites => {
self.toggle_section_expanded(*section, cx);
}
},
ListEntry::Contact { contact, calling } => {
if contact.online && !contact.busy && !calling {
self.call(contact.user.id, cx);
}
}
ListEntry::ParticipantProject {
project_id,
host_user_id,
..
} => {
if let Some(workspace) = self.workspace.upgrade() {
let app_state = workspace.read(cx).app_state().clone();
workspace::join_remote_project(
*project_id,
*host_user_id,
app_state,
cx,
)
.detach_and_log_err(cx);
}
}
ListEntry::ParticipantScreen { peer_id, .. } => {
let Some(peer_id) = peer_id else {
return;
};
if let Some(workspace) = self.workspace.upgrade() {
workspace.update(cx, |workspace, cx| {
workspace.open_shared_screen(*peer_id, cx)
});
}
}
ListEntry::GuestCount { .. } => {
let Some(room) = ActiveCall::global(cx).read(cx).room() else {
return;
};
let room = room.read(cx);
let Some(channel_id) = room.channel_id() else {
return;
};
if room.local_participant_is_admin() {
self.manage_members(channel_id, cx)
}
}
ListEntry::Channel { channel, .. } => {
let is_active = maybe!({
let call_channel = ActiveCall::global(cx)
.read(cx)
.room()?
.read(cx)
.channel_id()?;
Some(call_channel == channel.id)
})
.unwrap_or(false);
if is_active {
self.open_channel_notes(channel.id, cx)
} else {
self.join_channel(channel.id, cx)
}
}
ListEntry::ContactPlaceholder => self.toggle_contact_finder(cx),
ListEntry::CallParticipant { user, peer_id, .. } => {
if Some(user) == self.user_store.read(cx).current_user().as_ref() {
Self::leave_call(cx);
} else if let Some(peer_id) = peer_id {
self.workspace
.update(cx, |workspace, cx| workspace.follow(*peer_id, cx))
.ok();
}
}
ListEntry::IncomingRequest(user) => {
self.respond_to_contact_request(user.id, true, cx)
}
ListEntry::ChannelInvite(channel) => {
self.respond_to_channel_invite(channel.id, true, cx)
}
ListEntry::ChannelNotes { channel_id } => {
self.open_channel_notes(*channel_id, cx)
}
ListEntry::ChannelChat { channel_id } => {
self.join_channel_chat(*channel_id, cx)
}
ListEntry::OutgoingRequest(_) => {}
ListEntry::ChannelEditor { .. } => {}
}
}
}
}
fn insert_space(&mut self, _: &InsertSpace, cx: &mut ViewContext<Self>) {
if self.channel_editing_state.is_some() {
self.channel_name_editor.update(cx, |editor, cx| {
editor.insert(" ", cx);
});
}
}
fn confirm_channel_edit(&mut self, cx: &mut ViewContext<CollabPanel>) -> bool {
if let Some(editing_state) = &mut self.channel_editing_state {
match editing_state {
ChannelEditingState::Create {
location,
pending_name,
..
} => {
if pending_name.is_some() {
return false;
}
let channel_name = self.channel_name_editor.read(cx).text(cx);
*pending_name = Some(channel_name.clone());
self.channel_store
.update(cx, |channel_store, cx| {
channel_store.create_channel(&channel_name, *location, cx)
})
.detach();
cx.notify();
}
ChannelEditingState::Rename {
location,
pending_name,
} => {
if pending_name.is_some() {
return false;
}
let channel_name = self.channel_name_editor.read(cx).text(cx);
*pending_name = Some(channel_name.clone());
self.channel_store
.update(cx, |channel_store, cx| {
channel_store.rename(*location, &channel_name, cx)
})
.detach();
cx.notify();
}
}
cx.focus_self();
true
} else {
false
}
}
fn toggle_section_expanded(&mut self, section: Section, cx: &mut ViewContext<Self>) {
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(false, cx);
}
fn collapse_selected_channel(
&mut self,
_: &CollapseSelectedChannel,
cx: &mut ViewContext<Self>,
) {
let Some(channel_id) = self.selected_channel().map(|channel| channel.id) else {
return;
};
if self.is_channel_collapsed(channel_id) {
return;
}
self.toggle_channel_collapsed(channel_id, cx);
}
fn expand_selected_channel(&mut self, _: &ExpandSelectedChannel, cx: &mut ViewContext<Self>) {
let Some(id) = self.selected_channel().map(|channel| channel.id) else {
return;
};
if !self.is_channel_collapsed(id) {
return;
}
self.toggle_channel_collapsed(id, cx)
}
// fn toggle_channel_collapsed_action(
// &mut self,
// action: &ToggleCollapse,
// cx: &mut ViewContext<Self>,
// ) {
// self.toggle_channel_collapsed(action.location, cx);
// }
fn toggle_channel_collapsed<'a>(&mut self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
match self.collapsed_channels.binary_search(&channel_id) {
Ok(ix) => {
self.collapsed_channels.remove(ix);
}
Err(ix) => {
self.collapsed_channels.insert(ix, channel_id);
}
};
self.serialize(cx);
self.update_entries(true, cx);
cx.notify();
cx.focus_self();
}
fn is_channel_collapsed(&self, channel_id: ChannelId) -> bool {
self.collapsed_channels.binary_search(&channel_id).is_ok()
}
fn leave_call(cx: &mut WindowContext) {
ActiveCall::global(cx)
.update(cx, |call, cx| call.hang_up(cx))
.detach_and_log_err(cx);
}
fn toggle_contact_finder(&mut self, cx: &mut ViewContext<Self>) {
if let Some(workspace) = self.workspace.upgrade() {
workspace.update(cx, |workspace, cx| {
workspace.toggle_modal(cx, |cx| {
let mut finder = ContactFinder::new(self.user_store.clone(), cx);
finder.set_query(self.filter_editor.read(cx).text(cx), cx);
finder
});
});
}
}
fn new_root_channel(&mut self, cx: &mut ViewContext<Self>) {
self.channel_editing_state = Some(ChannelEditingState::Create {
location: None,
pending_name: None,
});
self.update_entries(false, cx);
self.select_channel_editor();
cx.focus_view(&self.channel_name_editor);
cx.notify();
}
fn select_channel_editor(&mut self) {
self.selection = self.entries.iter().position(|entry| match entry {
ListEntry::ChannelEditor { .. } => true,
_ => false,
});
}
fn new_subchannel(&mut self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
self.collapsed_channels
.retain(|channel| *channel != channel_id);
self.channel_editing_state = Some(ChannelEditingState::Create {
location: Some(channel_id),
pending_name: None,
});
self.update_entries(false, cx);
self.select_channel_editor();
cx.focus_view(&self.channel_name_editor);
cx.notify();
}
fn invite_members(&mut self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
self.show_channel_modal(channel_id, channel_modal::Mode::InviteMembers, cx);
}
fn manage_members(&mut self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
self.show_channel_modal(channel_id, channel_modal::Mode::ManageMembers, cx);
}
fn remove_selected_channel(&mut self, _: &Remove, cx: &mut ViewContext<Self>) {
if let Some(channel) = self.selected_channel() {
self.remove_channel(channel.id, cx)
}
}
fn rename_selected_channel(&mut self, _: &menu::SecondaryConfirm, cx: &mut ViewContext<Self>) {
if let Some(channel) = self.selected_channel() {
self.rename_channel(channel.id, cx);
}
}
fn rename_channel(&mut self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
let channel_store = self.channel_store.read(cx);
if !channel_store.is_channel_admin(channel_id) {
return;
}
if let Some(channel) = channel_store.channel_for_id(channel_id).cloned() {
self.channel_editing_state = Some(ChannelEditingState::Rename {
location: channel_id,
pending_name: None,
});
self.channel_name_editor.update(cx, |editor, cx| {
editor.set_text(channel.name.clone(), cx);
editor.select_all(&Default::default(), cx);
});
cx.focus_view(&self.channel_name_editor);
self.update_entries(false, cx);
self.select_channel_editor();
}
}
fn start_move_channel(&mut self, channel_id: ChannelId, _cx: &mut ViewContext<Self>) {
self.channel_clipboard = Some(ChannelMoveClipboard { channel_id });
}
fn start_move_selected_channel(&mut self, _: &StartMoveChannel, cx: &mut ViewContext<Self>) {
if let Some(channel) = self.selected_channel() {
self.start_move_channel(channel.id, cx);
}
}
fn move_channel_on_clipboard(
&mut self,
to_channel_id: ChannelId,
cx: &mut ViewContext<CollabPanel>,
) {
if let Some(clipboard) = self.channel_clipboard.take() {
self.channel_store.update(cx, |channel_store, cx| {
channel_store
.move_channel(clipboard.channel_id, Some(to_channel_id), cx)
.detach_and_log_err(cx)
})
}
}
fn open_channel_notes(&mut self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
if let Some(workspace) = self.workspace.upgrade() {
ChannelView::open(channel_id, workspace, cx).detach();
}
}
fn show_inline_context_menu(&mut self, _: &menu::ShowContextMenu, cx: &mut ViewContext<Self>) {
let Some(channel) = self.selected_channel() else {
return;
};
let Some(bounds) = self
.selection
.and_then(|ix| self.list_state.bounds_for_item(ix))
else {
return;
};
self.deploy_channel_context_menu(bounds.center(), channel.id, self.selection.unwrap(), cx);
cx.stop_propagation();
}
fn selected_channel(&self) -> Option<&Arc<Channel>> {
self.selection
.and_then(|ix| self.entries.get(ix))
.and_then(|entry| match entry {
ListEntry::Channel { channel, .. } => Some(channel),
_ => None,
})
}
fn show_channel_modal(
&mut self,
channel_id: ChannelId,
mode: channel_modal::Mode,
cx: &mut ViewContext<Self>,
) {
let workspace = self.workspace.clone();
let user_store = self.user_store.clone();
let channel_store = self.channel_store.clone();
let members = self.channel_store.update(cx, |channel_store, cx| {
channel_store.get_channel_member_details(channel_id, cx)
});
cx.spawn(|_, mut cx| async move {
let members = members.await?;
workspace.update(&mut cx, |workspace, cx| {
workspace.toggle_modal(cx, |cx| {
ChannelModal::new(
user_store.clone(),
channel_store.clone(),
channel_id,
mode,
members,
cx,
)
});
})
})
.detach();
}
fn remove_channel(&mut self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
let channel_store = self.channel_store.clone();
if let Some(channel) = channel_store.read(cx).channel_for_id(channel_id) {
let prompt_message = format!(
"Are you sure you want to remove the channel \"{}\"?",
channel.name
);
let answer = cx.prompt(PromptLevel::Warning, &prompt_message, &["Remove", "Cancel"]);
cx.spawn(|this, mut cx| async move {
if answer.await? == 0 {
channel_store
.update(&mut cx, |channels, _| channels.remove_channel(channel_id))?
.await
.notify_async_err(&mut cx);
this.update(&mut cx, |_, cx| cx.focus_self()).ok();
}
anyhow::Ok(())
})
.detach();
}
}
fn remove_contact(&mut self, user_id: u64, github_login: &str, cx: &mut ViewContext<Self>) {
let user_store = self.user_store.clone();
let prompt_message = format!(
"Are you sure you want to remove \"{}\" from your contacts?",
github_login
);
let answer = cx.prompt(PromptLevel::Warning, &prompt_message, &["Remove", "Cancel"]);
cx.spawn(|_, mut cx| async move {
if answer.await? == 0 {
user_store
.update(&mut cx, |store, cx| store.remove_contact(user_id, cx))?
.await
.notify_async_err(&mut cx);
}
anyhow::Ok(())
})
.detach_and_log_err(cx);
}
fn respond_to_contact_request(
&mut self,
user_id: u64,
accept: bool,
cx: &mut ViewContext<Self>,
) {
self.user_store
.update(cx, |store, cx| {
store.respond_to_contact_request(user_id, accept, cx)
})
.detach_and_log_err(cx);
}
fn respond_to_channel_invite(
&mut self,
channel_id: u64,
accept: bool,
cx: &mut ViewContext<Self>,
) {
self.channel_store
.update(cx, |store, cx| {
store.respond_to_channel_invite(channel_id, accept, cx)
})
.detach();
}
fn call(&mut self, recipient_user_id: u64, cx: &mut ViewContext<Self>) {
ActiveCall::global(cx)
.update(cx, |call, cx| {
call.invite(recipient_user_id, Some(self.project.clone()), cx)
})
.detach_and_log_err(cx);
}
fn join_channel(&self, channel_id: u64, cx: &mut ViewContext<Self>) {
let Some(workspace) = self.workspace.upgrade() else {
return;
};
let Some(handle) = cx.window_handle().downcast::<Workspace>() else {
return;
};
workspace::join_channel(
channel_id,
workspace.read(cx).app_state().clone(),
Some(handle),
cx,
)
.detach_and_log_err(cx)
}
fn join_channel_chat(&mut self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
let Some(workspace) = self.workspace.upgrade() else {
return;
};
cx.window_context().defer(move |cx| {
workspace.update(cx, |workspace, cx| {
if let Some(panel) = workspace.focus_panel::<ChatPanel>(cx) {
panel.update(cx, |panel, cx| {
panel
.select_channel(channel_id, None, cx)
.detach_and_log_err(cx);
});
}
});
});
}
fn copy_channel_link(&mut self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
let channel_store = self.channel_store.read(cx);
let Some(channel) = channel_store.channel_for_id(channel_id) else {
return;
};
let item = ClipboardItem::new(channel.link());
cx.write_to_clipboard(item)
}
fn render_signed_out(&mut self, cx: &mut ViewContext<Self>) -> Div {
let collab_blurb = "Work with your team in realtime with collaborative editing, voice, shared notes and more.";
v_stack()
.gap_6()
.p_4()
.child(Label::new(collab_blurb))
.child(
v_stack()
.gap_2()
.child(
Button::new("sign_in", "Sign in")
.icon_color(Color::Muted)
.icon(IconName::Github)
.icon_position(IconPosition::Start)
.style(ButtonStyle::Filled)
.full_width()
.on_click(cx.listener(|this, _, cx| {
let client = this.client.clone();
cx.spawn(|_, mut cx| async move {
client
.authenticate_and_connect(true, &cx)
.await
.notify_async_err(&mut cx);
})
.detach()
})),
)
.child(
div().flex().w_full().items_center().child(
Label::new("Sign in to enable collaboration.")
.color(Color::Muted)
.size(LabelSize::Small),
),
),
)
}
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::ChannelInvite(channel) => self
.render_channel_invite(channel, is_selected, cx)
.into_any_element(),
ListEntry::CallParticipant {
user,
peer_id,
is_pending,
} => self
.render_call_participant(user, *peer_id, *is_pending, is_selected, 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,
is_selected,
cx,
)
.into_any_element(),
ListEntry::ParticipantScreen { peer_id, is_last } => self
.render_participant_screen(*peer_id, *is_last, is_selected, cx)
.into_any_element(),
ListEntry::GuestCount {
count,
has_visible_participants,
} => self
.render_guest_count(*count, *has_visible_participants, is_selected, cx)
.into_any_element(),
ListEntry::ChannelNotes { channel_id } => self
.render_channel_notes(*channel_id, is_selected, cx)
.into_any_element(),
ListEntry::ChannelChat { channel_id } => self
.render_channel_chat(*channel_id, is_selected, cx)
.into_any_element(),
}
}
fn render_signed_in(&mut self, cx: &mut ViewContext<Self>) -> Div {
v_stack()
.size_full()
.child(list(self.list_state.clone()).full())
.child(
v_stack()
.child(div().mx_2().border_primary(cx).border_t())
.child(
v_stack()
.p_2()
.child(self.render_filter_input(&self.filter_editor, cx)),
),
)
}
fn render_filter_input(
&self,
editor: &View<Editor>,
cx: &mut ViewContext<Self>,
) -> impl IntoElement {
let settings = ThemeSettings::get_global(cx);
let text_style = TextStyle {
color: if editor.read(cx).read_only(cx) {
cx.theme().colors().text_disabled
} else {
cx.theme().colors().text
},
font_family: settings.ui_font.family.clone(),
font_features: settings.ui_font.features,
font_size: rems(0.875).into(),
font_weight: FontWeight::NORMAL,
font_style: FontStyle::Normal,
line_height: relative(1.3).into(),
background_color: None,
underline: None,
white_space: WhiteSpace::Normal,
};
EditorElement::new(
editor,
EditorStyle {
local_player: cx.theme().players().local(),
text: text_style,
..Default::default()
},
)
}
fn render_header(
&self,
section: Section,
is_selected: bool,
is_collapsed: bool,
cx: &ViewContext<Self>,
) -> impl IntoElement {
let mut channel_link = None;
let mut channel_tooltip_text = None;
let mut channel_icon = None;
// let mut is_dragged_over = false;
let text = match section {
Section::ActiveCall => {
let channel_name = maybe!({
let channel_id = ActiveCall::global(cx).read(cx).channel_id(cx)?;
let channel = self.channel_store.read(cx).channel_for_id(channel_id)?;
channel_link = Some(channel.link());
(channel_icon, channel_tooltip_text) = match channel.visibility {
proto::ChannelVisibility::Public => {
(Some("icons/public.svg"), Some("Copy public channel link."))
}
proto::ChannelVisibility::Members => {
(Some("icons/hash.svg"), Some("Copy private channel link."))
}
};
Some(channel.name.as_ref())
});
if let Some(name) = channel_name {
SharedString::from(format!("{}", name))
} else {
SharedString::from("Current Call")
}
}
Section::ContactRequests => SharedString::from("Requests"),
Section::Contacts => SharedString::from("Contacts"),
Section::Channels => SharedString::from("Channels"),
Section::ChannelInvites => SharedString::from("Invites"),
Section::Online => SharedString::from("Online"),
Section::Offline => SharedString::from("Offline"),
};
let button = match section {
Section::ActiveCall => channel_link.map(|channel_link| {
let channel_link_copy = channel_link.clone();
IconButton::new("channel-link", IconName::Copy)
.icon_size(IconSize::Small)
.size(ButtonSize::None)
.visible_on_hover("section-header")
.on_click(move |_, cx| {
let item = ClipboardItem::new(channel_link_copy.clone());
cx.write_to_clipboard(item)
})
.tooltip(|cx| Tooltip::text("Copy channel link", cx))
.into_any_element()
}),
Section::Contacts => Some(
IconButton::new("add-contact", IconName::Plus)
.on_click(cx.listener(|this, _, cx| this.toggle_contact_finder(cx)))
.tooltip(|cx| Tooltip::text("Search for new contact", cx))
.into_any_element(),
),
Section::Channels => Some(
IconButton::new("add-channel", IconName::Plus)
.on_click(cx.listener(|this, _, cx| this.new_root_channel(cx)))
.tooltip(|cx| Tooltip::text("Create a channel", cx))
.into_any_element(),
),
_ => None,
};
let can_collapse = match section {
Section::ActiveCall | Section::Channels | Section::Contacts => false,
Section::ChannelInvites
| Section::ContactRequests
| Section::Online
| Section::Offline => true,
};
h_stack()
.w_full()
.group("section-header")
.child(
ListHeader::new(text)
.when(can_collapse, |header| {
header.toggle(Some(!is_collapsed)).on_toggle(cx.listener(
move |this, _, cx| {
this.toggle_section_expanded(section, cx);
},
))
})
.inset(true)
.end_slot::<AnyElement>(button)
.selected(is_selected),
)
.when(section == Section::Channels, |el| {
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(dragged_channel.id, None, cx)
})
.detach_and_log_err(cx)
}))
})
}
fn render_contact(
&self,
contact: &Contact,
calling: bool,
is_selected: bool,
cx: &mut ViewContext<Self>,
) -> impl IntoElement {
let online = contact.online;
let busy = contact.busy || calling;
let user_id = contact.user.id;
let github_login = SharedString::from(contact.user.github_login.clone());
let item =
ListItem::new(github_login.clone())
.indent_level(1)
.indent_step_size(px(20.))
.selected(is_selected)
.on_click(cx.listener(move |this, _, cx| this.call(user_id, cx)))
.child(
h_stack()
.w_full()
.justify_between()
.child(Label::new(github_login.clone()))
.when(calling, |el| {
el.child(Label::new("Calling").color(Color::Muted))
})
.when(!calling, |el| {
el.child(
IconButton::new("remove_contact", IconName::Close)
.icon_color(Color::Muted)
.visible_on_hover("")
.tooltip(|cx| Tooltip::text("Remove Contact", cx))
.on_click(cx.listener({
let github_login = github_login.clone();
move |this, _, cx| {
this.remove_contact(user_id, &github_login, cx);
}
})),
)
}),
)
.start_slot(
// todo!() handle contacts with no avatar
Avatar::new(contact.user.avatar_uri.clone())
.availability_indicator(if online { Some(!busy) } else { None }),
)
.when(online && !busy, |el| {
el.on_click(cx.listener(move |this, _, cx| this.call(user_id, cx)))
});
div()
.id(github_login.clone())
.group("")
.child(item)
.tooltip(move |cx| {
let text = if !online {
format!(" {} is offline", &github_login)
} else if busy {
format!(" {} is on a call", &github_login)
} else {
let room = ActiveCall::global(cx).read(cx).room();
if room.is_some() {
format!("Invite {} to join call", &github_login)
} else {
format!("Call {}", &github_login)
}
};
Tooltip::text(text, cx)
})
}
fn render_contact_request(
&self,
user: &Arc<User>,
is_incoming: bool,
is_selected: bool,
cx: &mut ViewContext<Self>,
) -> impl IntoElement {
let github_login = SharedString::from(user.github_login.clone());
let user_id = user.id;
let is_response_pending = self.user_store.read(cx).is_contact_request_pending(&user);
let color = if is_response_pending {
Color::Muted
} else {
Color::Default
};
let controls = if is_incoming {
vec![
IconButton::new("decline-contact", IconName::Close)
.on_click(cx.listener(move |this, _, cx| {
this.respond_to_contact_request(user_id, false, cx);
}))
.icon_color(color)
.tooltip(|cx| Tooltip::text("Decline invite", cx)),
IconButton::new("accept-contact", IconName::Check)
.on_click(cx.listener(move |this, _, cx| {
this.respond_to_contact_request(user_id, true, cx);
}))
.icon_color(color)
.tooltip(|cx| Tooltip::text("Accept invite", cx)),
]
} else {
let github_login = github_login.clone();
vec![IconButton::new("remove_contact", IconName::Close)
.on_click(cx.listener(move |this, _, cx| {
this.remove_contact(user_id, &github_login, cx);
}))
.icon_color(color)
.tooltip(|cx| Tooltip::text("Cancel invite", cx))]
};
ListItem::new(github_login.clone())
.indent_level(1)
.indent_step_size(px(20.))
.selected(is_selected)
.child(
h_stack()
.w_full()
.justify_between()
.child(Label::new(github_login.clone()))
.child(h_stack().children(controls)),
)
.start_slot(Avatar::new(user.avatar_uri.clone()))
}
fn render_channel_invite(
&self,
channel: &Arc<Channel>,
is_selected: bool,
cx: &mut ViewContext<Self>,
) -> ListItem {
let channel_id = channel.id;
let response_is_pending = self
.channel_store
.read(cx)
.has_pending_channel_invite_response(&channel);
let color = if response_is_pending {
Color::Muted
} else {
Color::Default
};
let controls = [
IconButton::new("reject-invite", IconName::Close)
.on_click(cx.listener(move |this, _, cx| {
this.respond_to_channel_invite(channel_id, false, cx);
}))
.icon_color(color)
.tooltip(|cx| Tooltip::text("Decline invite", cx)),
IconButton::new("accept-invite", IconName::Check)
.on_click(cx.listener(move |this, _, cx| {
this.respond_to_channel_invite(channel_id, true, cx);
}))
.icon_color(color)
.tooltip(|cx| Tooltip::text("Accept invite", cx)),
];
ListItem::new(("channel-invite", channel.id as usize))
.selected(is_selected)
.child(
h_stack()
.w_full()
.justify_between()
.child(Label::new(channel.name.clone()))
.child(h_stack().children(controls)),
)
.start_slot(
Icon::new(IconName::Hash)
.size(IconSize::Small)
.color(Color::Muted),
)
}
fn render_contact_placeholder(
&self,
is_selected: bool,
cx: &mut ViewContext<Self>,
) -> ListItem {
ListItem::new("contact-placeholder")
.child(Icon::new(IconName::Plus))
.child(Label::new("Add a Contact"))
.selected(is_selected)
.on_click(cx.listener(|this, _, cx| this.toggle_contact_finder(cx)))
}
fn render_channel(
&self,
channel: &Channel,
depth: usize,
has_children: bool,
is_selected: bool,
ix: usize,
cx: &mut ViewContext<Self>,
) -> impl IntoElement {
let channel_id = channel.id;
let is_active = maybe!({
let call_channel = ActiveCall::global(cx)
.read(cx)
.room()?
.read(cx)
.channel_id()?;
Some(call_channel == channel_id)
})
.unwrap_or(false);
let is_public = self
.channel_store
.read(cx)
.channel_for_id(channel_id)
.map(|channel| channel.visibility)
== Some(proto::ChannelVisibility::Public);
let disclosed =
has_children.then(|| !self.collapsed_channels.binary_search(&channel.id).is_ok());
let has_messages_notification = channel.unseen_message_id.is_some();
let has_notes_notification = channel.unseen_note_version.is_some();
const FACEPILE_LIMIT: usize = 3;
let participants = self.channel_store.read(cx).channel_participants(channel_id);
let face_pile = if !participants.is_empty() {
let extra_count = participants.len().saturating_sub(FACEPILE_LIMIT);
let result = FacePile {
faces: participants
.iter()
.map(|user| Avatar::new(user.avatar_uri.clone()).into_any_element())
.take(FACEPILE_LIMIT)
.chain(if extra_count > 0 {
Some(
div()
.ml_1()
.child(Label::new(format!("+{extra_count}")))
.into_any_element(),
)
} else {
None
})
.collect::<SmallVec<_>>(),
};
Some(result)
} else {
None
};
let width = self.width.unwrap_or(px(240.));
div()
.id(channel_id as usize)
.group("")
.flex()
.w_full()
.on_drag(channel.clone(), move |channel, cx| {
cx.new_view(|_| DraggedChannelView {
channel: channel.clone(),
width,
})
})
.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)
// Add one level of depth for the disclosure arrow.
.indent_level(depth + 1)
.indent_step_size(px(20.))
.selected(is_selected || is_active)
.toggle(disclosed)
.on_toggle(
cx.listener(move |this, _, cx| {
this.toggle_channel_collapsed(channel_id, cx)
}),
)
.on_click(cx.listener(move |this, _, cx| {
if is_active {
this.open_channel_notes(channel_id, cx)
} else {
this.join_channel(channel_id, cx)
}
}))
.on_secondary_mouse_down(cx.listener(
move |this, event: &MouseDownEvent, cx| {
this.deploy_channel_context_menu(event.position, channel_id, ix, cx)
},
))
.start_slot(
Icon::new(if is_public {
IconName::Public
} else {
IconName::Hash
})
.size(IconSize::Small)
.color(Color::Muted),
)
.child(
h_stack()
.id(channel_id as usize)
.child(Label::new(channel.name.clone()))
.children(face_pile.map(|face_pile| face_pile.render(cx))),
),
)
.child(
h_stack()
.absolute()
.right(rems(0.))
.h_full()
// HACK: Without this the channel name clips on top of the icons, but I'm not sure why.
.z_index(10)
.child(
h_stack()
.h_full()
.gap_1()
.px_1()
.child(
IconButton::new("channel_chat", IconName::MessageBubbles)
.style(ButtonStyle::Filled)
.size(ButtonSize::Compact)
.icon_size(IconSize::Small)
.icon_color(if has_messages_notification {
Color::Default
} else {
Color::Muted
})
.on_click(cx.listener(move |this, _, cx| {
this.join_channel_chat(channel_id, cx)
}))
.tooltip(|cx| Tooltip::text("Open channel chat", cx))
.when(!has_messages_notification, |this| {
this.visible_on_hover("")
}),
)
.child(
IconButton::new("channel_notes", IconName::File)
.style(ButtonStyle::Filled)
.size(ButtonSize::Compact)
.icon_size(IconSize::Small)
.icon_color(if has_notes_notification {
Color::Default
} else {
Color::Muted
})
.on_click(cx.listener(move |this, _, cx| {
this.open_channel_notes(channel_id, cx)
}))
.tooltip(|cx| Tooltip::text("Open channel notes", cx))
.when(!has_notes_notification, |this| {
this.visible_on_hover("")
}),
),
),
)
.tooltip(|cx| Tooltip::text("Join channel", cx))
}
fn render_channel_editor(&self, depth: usize, _cx: &mut ViewContext<Self>) -> impl IntoElement {
let item = ListItem::new("channel-editor")
.inset(false)
// Add one level of depth for the disclosure arrow.
.indent_level(depth + 1)
.indent_step_size(px(20.))
.start_slot(
Icon::new(IconName::Hash)
.size(IconSize::Small)
.color(Color::Muted),
);
if let Some(pending_name) = self
.channel_editing_state
.as_ref()
.and_then(|state| state.pending_name())
{
item.child(Label::new(pending_name))
} else {
item.child(self.channel_name_editor.clone())
}
}
}
fn render_tree_branch(is_last: bool, overdraw: bool, cx: &mut WindowContext) -> impl IntoElement {
let rem_size = cx.rem_size();
let line_height = cx.text_style().line_height_in_pixels(rem_size);
let width = rem_size * 1.5;
let thickness = px(1.);
let color = cx.theme().colors().text;
canvas(move |bounds, cx| {
let start_x = (bounds.left() + bounds.right() - thickness) / 2.;
let start_y = (bounds.top() + bounds.bottom() - thickness) / 2.;
let right = bounds.right();
let top = bounds.top();
cx.paint_quad(fill(
Bounds::from_corners(
point(start_x, top),
point(
start_x + thickness,
if is_last {
start_y
} else {
bounds.bottom() + if overdraw { px(1.) } else { px(0.) }
},
),
),
color,
));
cx.paint_quad(fill(
Bounds::from_corners(point(start_x, start_y), point(right, start_y + thickness)),
color,
));
})
.w(width)
.h(line_height)
}
impl Render for CollabPanel {
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
v_stack()
.key_context("CollabPanel")
.on_action(cx.listener(CollabPanel::cancel))
.on_action(cx.listener(CollabPanel::select_next))
.on_action(cx.listener(CollabPanel::select_prev))
.on_action(cx.listener(CollabPanel::confirm))
.on_action(cx.listener(CollabPanel::insert_space))
.on_action(cx.listener(CollabPanel::remove_selected_channel))
.on_action(cx.listener(CollabPanel::show_inline_context_menu))
.on_action(cx.listener(CollabPanel::rename_selected_channel))
.on_action(cx.listener(CollabPanel::collapse_selected_channel))
.on_action(cx.listener(CollabPanel::expand_selected_channel))
.on_action(cx.listener(CollabPanel::start_move_selected_channel))
.track_focus(&self.focus_handle)
.size_full()
.child(if self.user_store.read(cx).current_user().is_none() {
self.render_signed_out(cx)
} else {
self.render_signed_in(cx)
})
.children(self.context_menu.as_ref().map(|(menu, position, _)| {
overlay()
.position(*position)
.anchor(gpui::AnchorCorner::TopLeft)
.child(menu.clone())
}))
}
}
impl EventEmitter<PanelEvent> for CollabPanel {}
impl Panel for CollabPanel {
fn position(&self, cx: &gpui::WindowContext) -> DockPosition {
CollaborationPanelSettings::get_global(cx).dock
}
fn position_is_valid(&self, position: DockPosition) -> bool {
matches!(position, DockPosition::Left | DockPosition::Right)
}
fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
settings::update_settings_file::<CollaborationPanelSettings>(
self.fs.clone(),
cx,
move |settings| settings.dock = Some(position),
);
}
fn size(&self, cx: &gpui::WindowContext) -> Pixels {
self.width
.unwrap_or_else(|| CollaborationPanelSettings::get_global(cx).default_width)
}
fn set_size(&mut self, size: Option<Pixels>, cx: &mut ViewContext<Self>) {
self.width = size;
self.serialize(cx);
cx.notify();
}
fn icon(&self, cx: &gpui::WindowContext) -> Option<ui::IconName> {
CollaborationPanelSettings::get_global(cx)
.button
.then(|| ui::IconName::Collab)
}
fn icon_tooltip(&self, _cx: &WindowContext) -> Option<&'static str> {
Some("Collab Panel")
}
fn toggle_action(&self) -> Box<dyn gpui::Action> {
Box::new(ToggleFocus)
}
fn persistent_name() -> &'static str {
"CollabPanel"
}
}
impl FocusableView for CollabPanel {
fn focus_handle(&self, cx: &AppContext) -> gpui::FocusHandle {
self.filter_editor.focus_handle(cx).clone()
}
}
impl PartialEq for ListEntry {
fn eq(&self, other: &Self) -> bool {
match self {
ListEntry::Header(section_1) => {
if let ListEntry::Header(section_2) = other {
return section_1 == section_2;
}
}
ListEntry::CallParticipant { user: user_1, .. } => {
if let ListEntry::CallParticipant { user: user_2, .. } = other {
return user_1.id == user_2.id;
}
}
ListEntry::ParticipantProject {
project_id: project_id_1,
..
} => {
if let ListEntry::ParticipantProject {
project_id: project_id_2,
..
} = other
{
return project_id_1 == project_id_2;
}
}
ListEntry::ParticipantScreen {
peer_id: peer_id_1, ..
} => {
if let ListEntry::ParticipantScreen {
peer_id: peer_id_2, ..
} = other
{
return peer_id_1 == peer_id_2;
}
}
ListEntry::Channel {
channel: channel_1, ..
} => {
if let ListEntry::Channel {
channel: channel_2, ..
} = other
{
return channel_1.id == channel_2.id;
}
}
ListEntry::ChannelNotes { channel_id } => {
if let ListEntry::ChannelNotes {
channel_id: other_id,
} = other
{
return channel_id == other_id;
}
}
ListEntry::ChannelChat { channel_id } => {
if let ListEntry::ChannelChat {
channel_id: other_id,
} = other
{
return channel_id == other_id;
}
}
ListEntry::ChannelInvite(channel_1) => {
if let ListEntry::ChannelInvite(channel_2) = other {
return channel_1.id == channel_2.id;
}
}
ListEntry::IncomingRequest(user_1) => {
if let ListEntry::IncomingRequest(user_2) = other {
return user_1.id == user_2.id;
}
}
ListEntry::OutgoingRequest(user_1) => {
if let ListEntry::OutgoingRequest(user_2) = other {
return user_1.id == user_2.id;
}
}
ListEntry::Contact {
contact: contact_1, ..
} => {
if let ListEntry::Contact {
contact: contact_2, ..
} = other
{
return contact_1.user.id == contact_2.user.id;
}
}
ListEntry::ChannelEditor { depth } => {
if let ListEntry::ChannelEditor { depth: other_depth } = other {
return depth == other_depth;
}
}
ListEntry::ContactPlaceholder => {
if let ListEntry::ContactPlaceholder = other {
return true;
}
}
ListEntry::GuestCount { .. } => {
if let ListEntry::GuestCount { .. } = other {
return true;
}
}
}
false
}
}
struct DraggedChannelView {
channel: Channel,
width: Pixels,
}
impl Render for DraggedChannelView {
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl Element {
let ui_font = ThemeSettings::get_global(cx).ui_font.family.clone();
h_stack()
.font(ui_font)
.bg(cx.theme().colors().background)
.w(self.width)
.p_1()
.gap_1()
.child(
Icon::new(
if self.channel.visibility == proto::ChannelVisibility::Public {
IconName::Public
} else {
IconName::Hash
},
)
.size(IconSize::Small)
.color(Color::Muted),
)
.child(Label::new(self.channel.name.clone()))
}
}