1mod channel_modal;
2mod contact_finder;
3
4use self::channel_modal::ChannelModal;
5use crate::{
6 channel_view::ChannelView, chat_panel::ChatPanel, face_pile::FacePile,
7 CollaborationPanelSettings,
8};
9use call::ActiveCall;
10use channel::{Channel, ChannelEvent, ChannelId, ChannelStore};
11use client::{Client, Contact, User, UserStore};
12use contact_finder::ContactFinder;
13use db::kvp::KEY_VALUE_STORE;
14use editor::{Editor, EditorElement, EditorStyle};
15use feature_flags::{ChannelsAlpha, FeatureFlagAppExt, FeatureFlagViewExt};
16use fuzzy::{match_strings, StringMatchCandidate};
17use gpui::{
18 actions, canvas, div, fill, list, overlay, point, prelude::*, px, AnyElement, AppContext,
19 AsyncWindowContext, Bounds, ClipboardItem, DismissEvent, Div, EventEmitter, FocusHandle,
20 FocusableView, FontStyle, FontWeight, InteractiveElement, IntoElement, ListOffset, ListState,
21 Model, MouseDownEvent, ParentElement, Pixels, Point, PromptLevel, Render, RenderOnce,
22 SharedString, Styled, Subscription, Task, TextStyle, View, ViewContext, VisualContext,
23 WeakView, WhiteSpace,
24};
25use menu::{Cancel, Confirm, SelectNext, SelectPrev};
26use project::{Fs, Project};
27use rpc::proto::{self, PeerId};
28use serde_derive::{Deserialize, Serialize};
29use settings::{Settings, SettingsStore};
30use smallvec::SmallVec;
31use std::{mem, sync::Arc};
32use theme::{ActiveTheme, ThemeSettings};
33use ui::{
34 prelude::*, Avatar, Button, Color, ContextMenu, Icon, IconButton, IconElement, IconSize, Label,
35 ListHeader, ListItem, Tooltip,
36};
37use util::{maybe, ResultExt, TryFutureExt};
38use workspace::{
39 dock::{DockPosition, Panel, PanelEvent},
40 notifications::NotifyResultExt,
41 Workspace,
42};
43
44actions!(
45 collab_panel,
46 [
47 ToggleFocus,
48 Remove,
49 Secondary,
50 CollapseSelectedChannel,
51 ExpandSelectedChannel,
52 StartMoveChannel,
53 MoveSelected,
54 InsertSpace,
55 ]
56);
57
58#[derive(Debug, Copy, Clone, PartialEq, Eq)]
59struct ChannelMoveClipboard {
60 channel_id: ChannelId,
61}
62
63const COLLABORATION_PANEL_KEY: &'static str = "CollaborationPanel";
64
65pub fn init(cx: &mut AppContext) {
66 cx.observe_new_views(|workspace: &mut Workspace, _| {
67 workspace.register_action(|workspace, _: &ToggleFocus, cx| {
68 workspace.toggle_panel_focus::<CollabPanel>(cx);
69 });
70 })
71 .detach();
72}
73
74#[derive(Debug)]
75pub enum ChannelEditingState {
76 Create {
77 location: Option<ChannelId>,
78 pending_name: Option<String>,
79 },
80 Rename {
81 location: ChannelId,
82 pending_name: Option<String>,
83 },
84}
85
86impl ChannelEditingState {
87 fn pending_name(&self) -> Option<String> {
88 match self {
89 ChannelEditingState::Create { pending_name, .. } => pending_name.clone(),
90 ChannelEditingState::Rename { pending_name, .. } => pending_name.clone(),
91 }
92 }
93}
94
95pub struct CollabPanel {
96 width: Option<Pixels>,
97 fs: Arc<dyn Fs>,
98 focus_handle: FocusHandle,
99 channel_clipboard: Option<ChannelMoveClipboard>,
100 pending_serialization: Task<Option<()>>,
101 context_menu: Option<(View<ContextMenu>, Point<Pixels>, Subscription)>,
102 list_state: ListState,
103 filter_editor: View<Editor>,
104 channel_name_editor: View<Editor>,
105 channel_editing_state: Option<ChannelEditingState>,
106 entries: Vec<ListEntry>,
107 selection: Option<usize>,
108 channel_store: Model<ChannelStore>,
109 user_store: Model<UserStore>,
110 client: Arc<Client>,
111 project: Model<Project>,
112 match_candidates: Vec<StringMatchCandidate>,
113 subscriptions: Vec<Subscription>,
114 collapsed_sections: Vec<Section>,
115 collapsed_channels: Vec<ChannelId>,
116 workspace: WeakView<Workspace>,
117}
118
119#[derive(Serialize, Deserialize)]
120struct SerializedCollabPanel {
121 width: Option<Pixels>,
122 collapsed_channels: Option<Vec<u64>>,
123}
124
125#[derive(Clone, Copy, PartialEq, Eq, Debug, PartialOrd, Ord)]
126enum Section {
127 ActiveCall,
128 Channels,
129 ChannelInvites,
130 ContactRequests,
131 Contacts,
132 Online,
133 Offline,
134}
135
136#[derive(Clone, Debug)]
137enum ListEntry {
138 Header(Section),
139 CallParticipant {
140 user: Arc<User>,
141 peer_id: Option<PeerId>,
142 is_pending: bool,
143 },
144 ParticipantProject {
145 project_id: u64,
146 worktree_root_names: Vec<String>,
147 host_user_id: u64,
148 is_last: bool,
149 },
150 ParticipantScreen {
151 peer_id: Option<PeerId>,
152 is_last: bool,
153 },
154 GuestCount {
155 count: usize,
156 has_visible_participants: bool,
157 },
158 IncomingRequest(Arc<User>),
159 OutgoingRequest(Arc<User>),
160 ChannelInvite(Arc<Channel>),
161 Channel {
162 channel: Arc<Channel>,
163 depth: usize,
164 has_children: bool,
165 },
166 ChannelNotes {
167 channel_id: ChannelId,
168 },
169 ChannelChat {
170 channel_id: ChannelId,
171 },
172 ChannelEditor {
173 depth: usize,
174 },
175 Contact {
176 contact: Arc<Contact>,
177 calling: bool,
178 },
179 ContactPlaceholder,
180}
181
182impl CollabPanel {
183 pub fn new(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) -> View<Self> {
184 cx.new_view(|cx| {
185 let filter_editor = cx.new_view(|cx| {
186 let mut editor = Editor::single_line(cx);
187 editor.set_placeholder_text("Filter...", cx);
188 editor
189 });
190
191 cx.subscribe(&filter_editor, |this: &mut Self, _, event, cx| {
192 if let editor::EditorEvent::BufferEdited = event {
193 let query = this.filter_editor.read(cx).text(cx);
194 if !query.is_empty() {
195 this.selection.take();
196 }
197 this.update_entries(true, cx);
198 if !query.is_empty() {
199 this.selection = this
200 .entries
201 .iter()
202 .position(|entry| !matches!(entry, ListEntry::Header(_)));
203 }
204 }
205 })
206 .detach();
207
208 let channel_name_editor = cx.new_view(|cx| Editor::single_line(cx));
209
210 cx.subscribe(&channel_name_editor, |this: &mut Self, _, event, cx| {
211 if let editor::EditorEvent::Blurred = event {
212 if let Some(state) = &this.channel_editing_state {
213 if state.pending_name().is_some() {
214 return;
215 }
216 }
217 this.take_editing_state(cx);
218 this.update_entries(false, cx);
219 cx.notify();
220 }
221 })
222 .detach();
223
224 let view = cx.view().downgrade();
225 let list_state =
226 ListState::new(0, gpui::ListAlignment::Top, px(1000.), move |ix, cx| {
227 if let Some(view) = view.upgrade() {
228 view.update(cx, |view, cx| view.render_list_entry(ix, cx))
229 } else {
230 div().into_any()
231 }
232 });
233
234 let mut this = Self {
235 width: None,
236 focus_handle: cx.focus_handle(),
237 channel_clipboard: None,
238 fs: workspace.app_state().fs.clone(),
239 pending_serialization: Task::ready(None),
240 context_menu: None,
241 list_state,
242 channel_name_editor,
243 filter_editor,
244 entries: Vec::default(),
245 channel_editing_state: None,
246 selection: None,
247 channel_store: ChannelStore::global(cx),
248 user_store: workspace.user_store().clone(),
249 project: workspace.project().clone(),
250 subscriptions: Vec::default(),
251 match_candidates: Vec::default(),
252 collapsed_sections: vec![Section::Offline],
253 collapsed_channels: Vec::default(),
254 workspace: workspace.weak_handle(),
255 client: workspace.app_state().client.clone(),
256 };
257
258 this.update_entries(false, cx);
259
260 // Update the dock position when the setting changes.
261 let mut old_dock_position = this.position(cx);
262 this.subscriptions.push(cx.observe_global::<SettingsStore>(
263 move |this: &mut Self, cx| {
264 let new_dock_position = this.position(cx);
265 if new_dock_position != old_dock_position {
266 old_dock_position = new_dock_position;
267 cx.emit(PanelEvent::ChangePosition);
268 }
269 cx.notify();
270 },
271 ));
272
273 let active_call = ActiveCall::global(cx);
274 this.subscriptions
275 .push(cx.observe(&this.user_store, |this, _, cx| {
276 this.update_entries(true, cx)
277 }));
278 this.subscriptions
279 .push(cx.observe(&this.channel_store, |this, _, cx| {
280 this.update_entries(true, cx)
281 }));
282 this.subscriptions
283 .push(cx.observe(&active_call, |this, _, cx| this.update_entries(true, cx)));
284 this.subscriptions
285 .push(cx.observe_flag::<ChannelsAlpha, _>(move |_, this, cx| {
286 this.update_entries(true, cx)
287 }));
288 this.subscriptions.push(cx.subscribe(
289 &this.channel_store,
290 |this, _channel_store, e, cx| match e {
291 ChannelEvent::ChannelCreated(channel_id)
292 | ChannelEvent::ChannelRenamed(channel_id) => {
293 if this.take_editing_state(cx) {
294 this.update_entries(false, cx);
295 this.selection = this.entries.iter().position(|entry| {
296 if let ListEntry::Channel { channel, .. } = entry {
297 channel.id == *channel_id
298 } else {
299 false
300 }
301 });
302 }
303 }
304 },
305 ));
306
307 this
308 })
309 }
310
311 pub async fn load(
312 workspace: WeakView<Workspace>,
313 mut cx: AsyncWindowContext,
314 ) -> anyhow::Result<View<Self>> {
315 let serialized_panel = cx
316 .background_executor()
317 .spawn(async move { KEY_VALUE_STORE.read_kvp(COLLABORATION_PANEL_KEY) })
318 .await
319 .map_err(|_| anyhow::anyhow!("Failed to read collaboration panel from key value store"))
320 .log_err()
321 .flatten()
322 .map(|panel| serde_json::from_str::<SerializedCollabPanel>(&panel))
323 .transpose()
324 .log_err()
325 .flatten();
326
327 workspace.update(&mut cx, |workspace, cx| {
328 let panel = CollabPanel::new(workspace, cx);
329 if let Some(serialized_panel) = serialized_panel {
330 panel.update(cx, |panel, cx| {
331 panel.width = serialized_panel.width;
332 panel.collapsed_channels = serialized_panel
333 .collapsed_channels
334 .unwrap_or_else(|| Vec::new());
335 cx.notify();
336 });
337 }
338 panel
339 })
340 }
341
342 fn serialize(&mut self, cx: &mut ViewContext<Self>) {
343 let width = self.width;
344 let collapsed_channels = self.collapsed_channels.clone();
345 self.pending_serialization = cx.background_executor().spawn(
346 async move {
347 KEY_VALUE_STORE
348 .write_kvp(
349 COLLABORATION_PANEL_KEY.into(),
350 serde_json::to_string(&SerializedCollabPanel {
351 width,
352 collapsed_channels: Some(collapsed_channels),
353 })?,
354 )
355 .await?;
356 anyhow::Ok(())
357 }
358 .log_err(),
359 );
360 }
361
362 fn scroll_to_item(&mut self, ix: usize) {
363 self.list_state.scroll_to_reveal_item(ix)
364 }
365
366 fn update_entries(&mut self, select_same_item: bool, cx: &mut ViewContext<Self>) {
367 let channel_store = self.channel_store.read(cx);
368 let user_store = self.user_store.read(cx);
369 let query = self.filter_editor.read(cx).text(cx);
370 let executor = cx.background_executor().clone();
371
372 let prev_selected_entry = self.selection.and_then(|ix| self.entries.get(ix).cloned());
373 let old_entries = mem::take(&mut self.entries);
374 let mut scroll_to_top = false;
375
376 if let Some(room) = ActiveCall::global(cx).read(cx).room() {
377 self.entries.push(ListEntry::Header(Section::ActiveCall));
378 if !old_entries
379 .iter()
380 .any(|entry| matches!(entry, ListEntry::Header(Section::ActiveCall)))
381 {
382 scroll_to_top = true;
383 }
384
385 if !self.collapsed_sections.contains(&Section::ActiveCall) {
386 let room = room.read(cx);
387 let mut guest_count_ix = 0;
388 let mut guest_count = if room.read_only() { 1 } else { 0 };
389 let mut non_guest_count = if room.read_only() { 0 } else { 1 };
390
391 if let Some(channel_id) = room.channel_id() {
392 self.entries.push(ListEntry::ChannelNotes { channel_id });
393 self.entries.push(ListEntry::ChannelChat { channel_id });
394 guest_count_ix = self.entries.len();
395 }
396
397 // Populate the active user.
398 if let Some(user) = user_store.current_user() {
399 self.match_candidates.clear();
400 self.match_candidates.push(StringMatchCandidate {
401 id: 0,
402 string: user.github_login.clone(),
403 char_bag: user.github_login.chars().collect(),
404 });
405 let matches = executor.block(match_strings(
406 &self.match_candidates,
407 &query,
408 true,
409 usize::MAX,
410 &Default::default(),
411 executor.clone(),
412 ));
413 if !matches.is_empty() && !room.read_only() {
414 let user_id = user.id;
415 self.entries.push(ListEntry::CallParticipant {
416 user,
417 peer_id: None,
418 is_pending: false,
419 });
420 let mut projects = room.local_participant().projects.iter().peekable();
421 while let Some(project) = projects.next() {
422 self.entries.push(ListEntry::ParticipantProject {
423 project_id: project.id,
424 worktree_root_names: project.worktree_root_names.clone(),
425 host_user_id: user_id,
426 is_last: projects.peek().is_none() && !room.is_screen_sharing(),
427 });
428 }
429 if room.is_screen_sharing() {
430 self.entries.push(ListEntry::ParticipantScreen {
431 peer_id: None,
432 is_last: true,
433 });
434 }
435 }
436 }
437
438 // Populate remote participants.
439 self.match_candidates.clear();
440 self.match_candidates
441 .extend(
442 room.remote_participants()
443 .iter()
444 .filter_map(|(_, participant)| {
445 if participant.role == proto::ChannelRole::Guest {
446 guest_count += 1;
447 return None;
448 } else {
449 non_guest_count += 1;
450 }
451 Some(StringMatchCandidate {
452 id: participant.user.id as usize,
453 string: participant.user.github_login.clone(),
454 char_bag: participant.user.github_login.chars().collect(),
455 })
456 }),
457 );
458 let matches = executor.block(match_strings(
459 &self.match_candidates,
460 &query,
461 true,
462 usize::MAX,
463 &Default::default(),
464 executor.clone(),
465 ));
466 for mat in matches {
467 let user_id = mat.candidate_id as u64;
468 let participant = &room.remote_participants()[&user_id];
469 self.entries.push(ListEntry::CallParticipant {
470 user: participant.user.clone(),
471 peer_id: Some(participant.peer_id),
472 is_pending: false,
473 });
474 let mut projects = participant.projects.iter().peekable();
475 while let Some(project) = projects.next() {
476 self.entries.push(ListEntry::ParticipantProject {
477 project_id: project.id,
478 worktree_root_names: project.worktree_root_names.clone(),
479 host_user_id: participant.user.id,
480 is_last: projects.peek().is_none()
481 && participant.video_tracks.is_empty(),
482 });
483 }
484 if !participant.video_tracks.is_empty() {
485 self.entries.push(ListEntry::ParticipantScreen {
486 peer_id: Some(participant.peer_id),
487 is_last: true,
488 });
489 }
490 }
491 if guest_count > 0 {
492 self.entries.insert(
493 guest_count_ix,
494 ListEntry::GuestCount {
495 count: guest_count,
496 has_visible_participants: non_guest_count > 0,
497 },
498 );
499 }
500
501 // Populate pending participants.
502 self.match_candidates.clear();
503 self.match_candidates
504 .extend(room.pending_participants().iter().enumerate().map(
505 |(id, participant)| StringMatchCandidate {
506 id,
507 string: participant.github_login.clone(),
508 char_bag: participant.github_login.chars().collect(),
509 },
510 ));
511 let matches = executor.block(match_strings(
512 &self.match_candidates,
513 &query,
514 true,
515 usize::MAX,
516 &Default::default(),
517 executor.clone(),
518 ));
519 self.entries
520 .extend(matches.iter().map(|mat| ListEntry::CallParticipant {
521 user: room.pending_participants()[mat.candidate_id].clone(),
522 peer_id: None,
523 is_pending: true,
524 }));
525 }
526 }
527
528 let mut request_entries = Vec::new();
529
530 if cx.has_flag::<ChannelsAlpha>() {
531 self.entries.push(ListEntry::Header(Section::Channels));
532
533 if channel_store.channel_count() > 0 || self.channel_editing_state.is_some() {
534 self.match_candidates.clear();
535 self.match_candidates
536 .extend(channel_store.ordered_channels().enumerate().map(
537 |(ix, (_, channel))| StringMatchCandidate {
538 id: ix,
539 string: channel.name.clone().into(),
540 char_bag: channel.name.chars().collect(),
541 },
542 ));
543 let matches = executor.block(match_strings(
544 &self.match_candidates,
545 &query,
546 true,
547 usize::MAX,
548 &Default::default(),
549 executor.clone(),
550 ));
551 if let Some(state) = &self.channel_editing_state {
552 if matches!(state, ChannelEditingState::Create { location: None, .. }) {
553 self.entries.push(ListEntry::ChannelEditor { depth: 0 });
554 }
555 }
556 let mut collapse_depth = None;
557 for mat in matches {
558 let channel = channel_store.channel_at_index(mat.candidate_id).unwrap();
559 let depth = channel.parent_path.len();
560
561 if collapse_depth.is_none() && self.is_channel_collapsed(channel.id) {
562 collapse_depth = Some(depth);
563 } else if let Some(collapsed_depth) = collapse_depth {
564 if depth > collapsed_depth {
565 continue;
566 }
567 if self.is_channel_collapsed(channel.id) {
568 collapse_depth = Some(depth);
569 } else {
570 collapse_depth = None;
571 }
572 }
573
574 let has_children = channel_store
575 .channel_at_index(mat.candidate_id + 1)
576 .map_or(false, |next_channel| {
577 next_channel.parent_path.ends_with(&[channel.id])
578 });
579
580 match &self.channel_editing_state {
581 Some(ChannelEditingState::Create {
582 location: parent_id,
583 ..
584 }) if *parent_id == Some(channel.id) => {
585 self.entries.push(ListEntry::Channel {
586 channel: channel.clone(),
587 depth,
588 has_children: false,
589 });
590 self.entries
591 .push(ListEntry::ChannelEditor { depth: depth + 1 });
592 }
593 Some(ChannelEditingState::Rename {
594 location: parent_id,
595 ..
596 }) if parent_id == &channel.id => {
597 self.entries.push(ListEntry::ChannelEditor { depth });
598 }
599 _ => {
600 self.entries.push(ListEntry::Channel {
601 channel: channel.clone(),
602 depth,
603 has_children,
604 });
605 }
606 }
607 }
608 }
609
610 let channel_invites = channel_store.channel_invitations();
611 if !channel_invites.is_empty() {
612 self.match_candidates.clear();
613 self.match_candidates
614 .extend(channel_invites.iter().enumerate().map(|(ix, channel)| {
615 StringMatchCandidate {
616 id: ix,
617 string: channel.name.clone().into(),
618 char_bag: channel.name.chars().collect(),
619 }
620 }));
621 let matches = executor.block(match_strings(
622 &self.match_candidates,
623 &query,
624 true,
625 usize::MAX,
626 &Default::default(),
627 executor.clone(),
628 ));
629 request_entries.extend(matches.iter().map(|mat| {
630 ListEntry::ChannelInvite(channel_invites[mat.candidate_id].clone())
631 }));
632
633 if !request_entries.is_empty() {
634 self.entries
635 .push(ListEntry::Header(Section::ChannelInvites));
636 if !self.collapsed_sections.contains(&Section::ChannelInvites) {
637 self.entries.append(&mut request_entries);
638 }
639 }
640 }
641 }
642
643 self.entries.push(ListEntry::Header(Section::Contacts));
644
645 request_entries.clear();
646 let incoming = user_store.incoming_contact_requests();
647 if !incoming.is_empty() {
648 self.match_candidates.clear();
649 self.match_candidates
650 .extend(
651 incoming
652 .iter()
653 .enumerate()
654 .map(|(ix, user)| StringMatchCandidate {
655 id: ix,
656 string: user.github_login.clone(),
657 char_bag: user.github_login.chars().collect(),
658 }),
659 );
660 let matches = executor.block(match_strings(
661 &self.match_candidates,
662 &query,
663 true,
664 usize::MAX,
665 &Default::default(),
666 executor.clone(),
667 ));
668 request_entries.extend(
669 matches
670 .iter()
671 .map(|mat| ListEntry::IncomingRequest(incoming[mat.candidate_id].clone())),
672 );
673 }
674
675 let outgoing = user_store.outgoing_contact_requests();
676 if !outgoing.is_empty() {
677 self.match_candidates.clear();
678 self.match_candidates
679 .extend(
680 outgoing
681 .iter()
682 .enumerate()
683 .map(|(ix, user)| StringMatchCandidate {
684 id: ix,
685 string: user.github_login.clone(),
686 char_bag: user.github_login.chars().collect(),
687 }),
688 );
689 let matches = executor.block(match_strings(
690 &self.match_candidates,
691 &query,
692 true,
693 usize::MAX,
694 &Default::default(),
695 executor.clone(),
696 ));
697 request_entries.extend(
698 matches
699 .iter()
700 .map(|mat| ListEntry::OutgoingRequest(outgoing[mat.candidate_id].clone())),
701 );
702 }
703
704 if !request_entries.is_empty() {
705 self.entries
706 .push(ListEntry::Header(Section::ContactRequests));
707 if !self.collapsed_sections.contains(&Section::ContactRequests) {
708 self.entries.append(&mut request_entries);
709 }
710 }
711
712 let contacts = user_store.contacts();
713 if !contacts.is_empty() {
714 self.match_candidates.clear();
715 self.match_candidates
716 .extend(
717 contacts
718 .iter()
719 .enumerate()
720 .map(|(ix, contact)| StringMatchCandidate {
721 id: ix,
722 string: contact.user.github_login.clone(),
723 char_bag: contact.user.github_login.chars().collect(),
724 }),
725 );
726
727 let matches = executor.block(match_strings(
728 &self.match_candidates,
729 &query,
730 true,
731 usize::MAX,
732 &Default::default(),
733 executor.clone(),
734 ));
735
736 let (online_contacts, offline_contacts) = matches
737 .iter()
738 .partition::<Vec<_>, _>(|mat| contacts[mat.candidate_id].online);
739
740 for (matches, section) in [
741 (online_contacts, Section::Online),
742 (offline_contacts, Section::Offline),
743 ] {
744 if !matches.is_empty() {
745 self.entries.push(ListEntry::Header(section));
746 if !self.collapsed_sections.contains(§ion) {
747 let active_call = &ActiveCall::global(cx).read(cx);
748 for mat in matches {
749 let contact = &contacts[mat.candidate_id];
750 self.entries.push(ListEntry::Contact {
751 contact: contact.clone(),
752 calling: active_call.pending_invites().contains(&contact.user.id),
753 });
754 }
755 }
756 }
757 }
758 }
759
760 if incoming.is_empty() && outgoing.is_empty() && contacts.is_empty() {
761 self.entries.push(ListEntry::ContactPlaceholder);
762 }
763
764 if select_same_item {
765 if let Some(prev_selected_entry) = prev_selected_entry {
766 self.selection.take();
767 for (ix, entry) in self.entries.iter().enumerate() {
768 if *entry == prev_selected_entry {
769 self.selection = Some(ix);
770 break;
771 }
772 }
773 }
774 } else {
775 self.selection = self.selection.and_then(|prev_selection| {
776 if self.entries.is_empty() {
777 None
778 } else {
779 Some(prev_selection.min(self.entries.len() - 1))
780 }
781 });
782 }
783
784 let old_scroll_top = self.list_state.logical_scroll_top();
785 self.list_state.reset(self.entries.len());
786
787 if scroll_to_top {
788 self.list_state.scroll_to(ListOffset::default());
789 } else {
790 // Attempt to maintain the same scroll position.
791 if let Some(old_top_entry) = old_entries.get(old_scroll_top.item_ix) {
792 let new_scroll_top = self
793 .entries
794 .iter()
795 .position(|entry| entry == old_top_entry)
796 .map(|item_ix| ListOffset {
797 item_ix,
798 offset_in_item: old_scroll_top.offset_in_item,
799 })
800 .or_else(|| {
801 let entry_after_old_top = old_entries.get(old_scroll_top.item_ix + 1)?;
802 let item_ix = self
803 .entries
804 .iter()
805 .position(|entry| entry == entry_after_old_top)?;
806 Some(ListOffset {
807 item_ix,
808 offset_in_item: Pixels::ZERO,
809 })
810 })
811 .or_else(|| {
812 let entry_before_old_top =
813 old_entries.get(old_scroll_top.item_ix.saturating_sub(1))?;
814 let item_ix = self
815 .entries
816 .iter()
817 .position(|entry| entry == entry_before_old_top)?;
818 Some(ListOffset {
819 item_ix,
820 offset_in_item: Pixels::ZERO,
821 })
822 });
823
824 self.list_state
825 .scroll_to(new_scroll_top.unwrap_or(old_scroll_top));
826 }
827 }
828
829 cx.notify();
830 }
831
832 fn render_call_participant(
833 &self,
834 user: &Arc<User>,
835 peer_id: Option<PeerId>,
836 is_pending: bool,
837 is_selected: bool,
838 cx: &mut ViewContext<Self>,
839 ) -> ListItem {
840 let is_current_user =
841 self.user_store.read(cx).current_user().map(|user| user.id) == Some(user.id);
842 let tooltip = format!("Follow {}", user.github_login);
843
844 ListItem::new(SharedString::from(user.github_login.clone()))
845 .start_slot(Avatar::new(user.avatar_uri.clone()))
846 .child(Label::new(user.github_login.clone()))
847 .selected(is_selected)
848 .end_slot(if is_pending {
849 Label::new("Calling").color(Color::Muted).into_any_element()
850 } else if is_current_user {
851 IconButton::new("leave-call", Icon::Exit)
852 .style(ButtonStyle::Subtle)
853 .on_click(move |_, cx| Self::leave_call(cx))
854 .tooltip(|cx| Tooltip::text("Leave Call", cx))
855 .into_any_element()
856 } else {
857 div().into_any_element()
858 })
859 .when_some(peer_id, |this, peer_id| {
860 this.tooltip(move |cx| Tooltip::text(tooltip.clone(), cx))
861 .on_click(cx.listener(move |this, _, cx| {
862 this.workspace
863 .update(cx, |workspace, cx| workspace.follow(peer_id, cx))
864 .ok();
865 }))
866 })
867 }
868
869 fn render_participant_project(
870 &self,
871 project_id: u64,
872 worktree_root_names: &[String],
873 host_user_id: u64,
874 is_last: bool,
875 is_selected: bool,
876 cx: &mut ViewContext<Self>,
877 ) -> impl IntoElement {
878 let project_name: SharedString = if worktree_root_names.is_empty() {
879 "untitled".to_string()
880 } else {
881 worktree_root_names.join(", ")
882 }
883 .into();
884
885 ListItem::new(project_id as usize)
886 .selected(is_selected)
887 .on_click(cx.listener(move |this, _, cx| {
888 this.workspace
889 .update(cx, |workspace, cx| {
890 let app_state = workspace.app_state().clone();
891 workspace::join_remote_project(project_id, host_user_id, app_state, cx)
892 .detach_and_log_err(cx);
893 })
894 .ok();
895 }))
896 .start_slot(
897 h_stack()
898 .gap_1()
899 .child(render_tree_branch(is_last, false, cx))
900 .child(IconButton::new(0, Icon::Folder)),
901 )
902 .child(Label::new(project_name.clone()))
903 .tooltip(move |cx| Tooltip::text(format!("Open {}", project_name), cx))
904 }
905
906 fn render_participant_screen(
907 &self,
908 peer_id: Option<PeerId>,
909 is_last: bool,
910 is_selected: bool,
911 cx: &mut ViewContext<Self>,
912 ) -> impl IntoElement {
913 let id = peer_id.map_or(usize::MAX, |id| id.as_u64() as usize);
914
915 ListItem::new(("screen", id))
916 .selected(is_selected)
917 .start_slot(
918 h_stack()
919 .gap_1()
920 .child(render_tree_branch(is_last, false, cx))
921 .child(IconButton::new(0, Icon::Screen)),
922 )
923 .child(Label::new("Screen"))
924 .when_some(peer_id, |this, _| {
925 this.on_click(cx.listener(move |this, _, cx| {
926 this.workspace
927 .update(cx, |workspace, cx| {
928 workspace.open_shared_screen(peer_id.unwrap(), cx)
929 })
930 .ok();
931 }))
932 .tooltip(move |cx| Tooltip::text(format!("Open shared screen"), cx))
933 })
934 }
935
936 fn take_editing_state(&mut self, cx: &mut ViewContext<Self>) -> bool {
937 if let Some(_) = self.channel_editing_state.take() {
938 self.channel_name_editor.update(cx, |editor, cx| {
939 editor.set_text("", cx);
940 });
941 true
942 } else {
943 false
944 }
945 }
946
947 fn render_channel_notes(
948 &self,
949 channel_id: ChannelId,
950 is_selected: bool,
951 cx: &mut ViewContext<Self>,
952 ) -> impl IntoElement {
953 ListItem::new("channel-notes")
954 .selected(is_selected)
955 .on_click(cx.listener(move |this, _, cx| {
956 this.open_channel_notes(channel_id, cx);
957 }))
958 .start_slot(
959 h_stack()
960 .gap_1()
961 .child(render_tree_branch(false, true, cx))
962 .child(IconButton::new(0, Icon::File)),
963 )
964 .child(div().h_7().w_full().child(Label::new("notes")))
965 .tooltip(move |cx| Tooltip::text("Open Channel Notes", cx))
966 }
967
968 fn render_channel_chat(
969 &self,
970 channel_id: ChannelId,
971 is_selected: bool,
972 cx: &mut ViewContext<Self>,
973 ) -> impl IntoElement {
974 ListItem::new("channel-chat")
975 .selected(is_selected)
976 .on_click(cx.listener(move |this, _, cx| {
977 this.join_channel_chat(channel_id, cx);
978 }))
979 .start_slot(
980 h_stack()
981 .gap_1()
982 .child(render_tree_branch(false, false, cx))
983 .child(IconButton::new(0, Icon::MessageBubbles)),
984 )
985 .child(Label::new("chat"))
986 .tooltip(move |cx| Tooltip::text("Open Chat", cx))
987 }
988
989 fn render_guest_count(
990 &self,
991 count: usize,
992 has_visible_participants: bool,
993 is_selected: bool,
994 cx: &mut ViewContext<Self>,
995 ) -> impl IntoElement {
996 let manageable_channel_id = ActiveCall::global(cx).read(cx).room().and_then(|room| {
997 let room = room.read(cx);
998 if room.local_participant_is_admin() {
999 room.channel_id()
1000 } else {
1001 None
1002 }
1003 });
1004
1005 ListItem::new("guest_count")
1006 .selected(is_selected)
1007 .start_slot(
1008 h_stack()
1009 .gap_1()
1010 .child(render_tree_branch(!has_visible_participants, false, cx))
1011 .child(""),
1012 )
1013 .child(Label::new(if count == 1 {
1014 format!("{} guest", count)
1015 } else {
1016 format!("{} guests", count)
1017 }))
1018 .when_some(manageable_channel_id, |el, channel_id| {
1019 el.tooltip(move |cx| Tooltip::text("Manage Members", cx))
1020 .on_click(cx.listener(move |this, _, cx| this.manage_members(channel_id, cx)))
1021 })
1022 }
1023
1024 fn has_subchannels(&self, ix: usize) -> bool {
1025 self.entries.get(ix).map_or(false, |entry| {
1026 if let ListEntry::Channel { has_children, .. } = entry {
1027 *has_children
1028 } else {
1029 false
1030 }
1031 })
1032 }
1033
1034 fn deploy_channel_context_menu(
1035 &mut self,
1036 position: Point<Pixels>,
1037 channel_id: ChannelId,
1038 ix: usize,
1039 cx: &mut ViewContext<Self>,
1040 ) {
1041 let clipboard_channel_name = self.channel_clipboard.as_ref().and_then(|clipboard| {
1042 self.channel_store
1043 .read(cx)
1044 .channel_for_id(clipboard.channel_id)
1045 .map(|channel| channel.name.clone())
1046 });
1047 let this = cx.view().clone();
1048
1049 let context_menu = ContextMenu::build(cx, |mut context_menu, cx| {
1050 if self.has_subchannels(ix) {
1051 let expand_action_name = if self.is_channel_collapsed(channel_id) {
1052 "Expand Subchannels"
1053 } else {
1054 "Collapse Subchannels"
1055 };
1056 context_menu = context_menu.entry(
1057 expand_action_name,
1058 None,
1059 cx.handler_for(&this, move |this, cx| {
1060 this.toggle_channel_collapsed(channel_id, cx)
1061 }),
1062 );
1063 }
1064
1065 context_menu = context_menu
1066 .entry(
1067 "Open Notes",
1068 None,
1069 cx.handler_for(&this, move |this, cx| {
1070 this.open_channel_notes(channel_id, cx)
1071 }),
1072 )
1073 .entry(
1074 "Open Chat",
1075 None,
1076 cx.handler_for(&this, move |this, cx| {
1077 this.join_channel_chat(channel_id, cx)
1078 }),
1079 )
1080 .entry(
1081 "Copy Channel Link",
1082 None,
1083 cx.handler_for(&this, move |this, cx| {
1084 this.copy_channel_link(channel_id, cx)
1085 }),
1086 );
1087
1088 if self.channel_store.read(cx).is_channel_admin(channel_id) {
1089 context_menu = context_menu
1090 .separator()
1091 .entry(
1092 "New Subchannel",
1093 None,
1094 cx.handler_for(&this, move |this, cx| this.new_subchannel(channel_id, cx)),
1095 )
1096 .entry(
1097 "Rename",
1098 None,
1099 cx.handler_for(&this, move |this, cx| this.rename_channel(channel_id, cx)),
1100 )
1101 .entry(
1102 "Move this channel",
1103 None,
1104 cx.handler_for(&this, move |this, cx| {
1105 this.start_move_channel(channel_id, cx)
1106 }),
1107 );
1108
1109 if let Some(channel_name) = clipboard_channel_name {
1110 context_menu = context_menu.separator().entry(
1111 format!("Move '#{}' here", channel_name),
1112 None,
1113 cx.handler_for(&this, move |this, cx| {
1114 this.move_channel_on_clipboard(channel_id, cx)
1115 }),
1116 );
1117 }
1118
1119 context_menu = context_menu
1120 .separator()
1121 .entry(
1122 "Invite Members",
1123 None,
1124 cx.handler_for(&this, move |this, cx| this.invite_members(channel_id, cx)),
1125 )
1126 .entry(
1127 "Manage Members",
1128 None,
1129 cx.handler_for(&this, move |this, cx| this.manage_members(channel_id, cx)),
1130 )
1131 .entry(
1132 "Delete",
1133 None,
1134 cx.handler_for(&this, move |this, cx| this.remove_channel(channel_id, cx)),
1135 );
1136 }
1137
1138 context_menu
1139 });
1140
1141 cx.focus_view(&context_menu);
1142 let subscription =
1143 cx.subscribe(&context_menu, |this, _, _: &DismissEvent, cx| {
1144 if this.context_menu.as_ref().is_some_and(|context_menu| {
1145 context_menu.0.focus_handle(cx).contains_focused(cx)
1146 }) {
1147 cx.focus_self();
1148 }
1149 this.context_menu.take();
1150 cx.notify();
1151 });
1152 self.context_menu = Some((context_menu, position, subscription));
1153
1154 cx.notify();
1155 }
1156
1157 fn cancel(&mut self, _: &Cancel, cx: &mut ViewContext<Self>) {
1158 if self.take_editing_state(cx) {
1159 cx.focus_view(&self.filter_editor);
1160 } else {
1161 self.filter_editor.update(cx, |editor, cx| {
1162 if editor.buffer().read(cx).len(cx) > 0 {
1163 editor.set_text("", cx);
1164 }
1165 });
1166 }
1167
1168 self.update_entries(false, cx);
1169 }
1170
1171 fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
1172 let ix = self.selection.map_or(0, |ix| ix + 1);
1173 if ix < self.entries.len() {
1174 self.selection = Some(ix);
1175 }
1176
1177 if let Some(ix) = self.selection {
1178 self.scroll_to_item(ix)
1179 }
1180 cx.notify();
1181 }
1182
1183 fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
1184 let ix = self.selection.take().unwrap_or(0);
1185 if ix > 0 {
1186 self.selection = Some(ix - 1);
1187 }
1188
1189 if let Some(ix) = self.selection {
1190 self.scroll_to_item(ix)
1191 }
1192 cx.notify();
1193 }
1194
1195 fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
1196 if self.confirm_channel_edit(cx) {
1197 return;
1198 }
1199
1200 if let Some(selection) = self.selection {
1201 if let Some(entry) = self.entries.get(selection) {
1202 match entry {
1203 ListEntry::Header(section) => match section {
1204 Section::ActiveCall => Self::leave_call(cx),
1205 Section::Channels => self.new_root_channel(cx),
1206 Section::Contacts => self.toggle_contact_finder(cx),
1207 Section::ContactRequests
1208 | Section::Online
1209 | Section::Offline
1210 | Section::ChannelInvites => {
1211 self.toggle_section_expanded(*section, cx);
1212 }
1213 },
1214 ListEntry::Contact { contact, calling } => {
1215 if contact.online && !contact.busy && !calling {
1216 self.call(contact.user.id, cx);
1217 }
1218 }
1219 ListEntry::ParticipantProject {
1220 project_id,
1221 host_user_id,
1222 ..
1223 } => {
1224 if let Some(workspace) = self.workspace.upgrade() {
1225 let app_state = workspace.read(cx).app_state().clone();
1226 workspace::join_remote_project(
1227 *project_id,
1228 *host_user_id,
1229 app_state,
1230 cx,
1231 )
1232 .detach_and_log_err(cx);
1233 }
1234 }
1235 ListEntry::ParticipantScreen { peer_id, .. } => {
1236 let Some(peer_id) = peer_id else {
1237 return;
1238 };
1239 if let Some(workspace) = self.workspace.upgrade() {
1240 workspace.update(cx, |workspace, cx| {
1241 workspace.open_shared_screen(*peer_id, cx)
1242 });
1243 }
1244 }
1245 ListEntry::GuestCount { .. } => {
1246 let Some(room) = ActiveCall::global(cx).read(cx).room() else {
1247 return;
1248 };
1249 let room = room.read(cx);
1250 let Some(channel_id) = room.channel_id() else {
1251 return;
1252 };
1253 if room.local_participant_is_admin() {
1254 self.manage_members(channel_id, cx)
1255 }
1256 }
1257 ListEntry::Channel { channel, .. } => {
1258 let is_active = maybe!({
1259 let call_channel = ActiveCall::global(cx)
1260 .read(cx)
1261 .room()?
1262 .read(cx)
1263 .channel_id()?;
1264
1265 Some(call_channel == channel.id)
1266 })
1267 .unwrap_or(false);
1268 if is_active {
1269 self.open_channel_notes(channel.id, cx)
1270 } else {
1271 self.join_channel(channel.id, cx)
1272 }
1273 }
1274 ListEntry::ContactPlaceholder => self.toggle_contact_finder(cx),
1275 ListEntry::CallParticipant { user, peer_id, .. } => {
1276 if Some(user) == self.user_store.read(cx).current_user().as_ref() {
1277 Self::leave_call(cx);
1278 } else if let Some(peer_id) = peer_id {
1279 self.workspace
1280 .update(cx, |workspace, cx| workspace.follow(*peer_id, cx))
1281 .ok();
1282 }
1283 }
1284 ListEntry::IncomingRequest(user) => {
1285 self.respond_to_contact_request(user.id, true, cx)
1286 }
1287 ListEntry::ChannelInvite(channel) => {
1288 self.respond_to_channel_invite(channel.id, true, cx)
1289 }
1290 ListEntry::ChannelNotes { channel_id } => {
1291 self.open_channel_notes(*channel_id, cx)
1292 }
1293 ListEntry::ChannelChat { channel_id } => {
1294 self.join_channel_chat(*channel_id, cx)
1295 }
1296
1297 ListEntry::OutgoingRequest(_) => {}
1298 ListEntry::ChannelEditor { .. } => {}
1299 }
1300 }
1301 }
1302 }
1303
1304 fn insert_space(&mut self, _: &InsertSpace, cx: &mut ViewContext<Self>) {
1305 if self.channel_editing_state.is_some() {
1306 self.channel_name_editor.update(cx, |editor, cx| {
1307 editor.insert(" ", cx);
1308 });
1309 }
1310 }
1311
1312 fn confirm_channel_edit(&mut self, cx: &mut ViewContext<CollabPanel>) -> bool {
1313 if let Some(editing_state) = &mut self.channel_editing_state {
1314 match editing_state {
1315 ChannelEditingState::Create {
1316 location,
1317 pending_name,
1318 ..
1319 } => {
1320 if pending_name.is_some() {
1321 return false;
1322 }
1323 let channel_name = self.channel_name_editor.read(cx).text(cx);
1324
1325 *pending_name = Some(channel_name.clone());
1326
1327 self.channel_store
1328 .update(cx, |channel_store, cx| {
1329 channel_store.create_channel(&channel_name, *location, cx)
1330 })
1331 .detach();
1332 cx.notify();
1333 }
1334 ChannelEditingState::Rename {
1335 location,
1336 pending_name,
1337 } => {
1338 if pending_name.is_some() {
1339 return false;
1340 }
1341 let channel_name = self.channel_name_editor.read(cx).text(cx);
1342 *pending_name = Some(channel_name.clone());
1343
1344 self.channel_store
1345 .update(cx, |channel_store, cx| {
1346 channel_store.rename(*location, &channel_name, cx)
1347 })
1348 .detach();
1349 cx.notify();
1350 }
1351 }
1352 cx.focus_self();
1353 true
1354 } else {
1355 false
1356 }
1357 }
1358
1359 fn toggle_section_expanded(&mut self, section: Section, cx: &mut ViewContext<Self>) {
1360 if let Some(ix) = self.collapsed_sections.iter().position(|s| *s == section) {
1361 self.collapsed_sections.remove(ix);
1362 } else {
1363 self.collapsed_sections.push(section);
1364 }
1365 self.update_entries(false, cx);
1366 }
1367
1368 fn collapse_selected_channel(
1369 &mut self,
1370 _: &CollapseSelectedChannel,
1371 cx: &mut ViewContext<Self>,
1372 ) {
1373 let Some(channel_id) = self.selected_channel().map(|channel| channel.id) else {
1374 return;
1375 };
1376
1377 if self.is_channel_collapsed(channel_id) {
1378 return;
1379 }
1380
1381 self.toggle_channel_collapsed(channel_id, cx);
1382 }
1383
1384 fn expand_selected_channel(&mut self, _: &ExpandSelectedChannel, cx: &mut ViewContext<Self>) {
1385 let Some(id) = self.selected_channel().map(|channel| channel.id) else {
1386 return;
1387 };
1388
1389 if !self.is_channel_collapsed(id) {
1390 return;
1391 }
1392
1393 self.toggle_channel_collapsed(id, cx)
1394 }
1395
1396 // fn toggle_channel_collapsed_action(
1397 // &mut self,
1398 // action: &ToggleCollapse,
1399 // cx: &mut ViewContext<Self>,
1400 // ) {
1401 // self.toggle_channel_collapsed(action.location, cx);
1402 // }
1403
1404 fn toggle_channel_collapsed<'a>(&mut self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
1405 match self.collapsed_channels.binary_search(&channel_id) {
1406 Ok(ix) => {
1407 self.collapsed_channels.remove(ix);
1408 }
1409 Err(ix) => {
1410 self.collapsed_channels.insert(ix, channel_id);
1411 }
1412 };
1413 self.serialize(cx);
1414 self.update_entries(true, cx);
1415 cx.notify();
1416 cx.focus_self();
1417 }
1418
1419 fn is_channel_collapsed(&self, channel_id: ChannelId) -> bool {
1420 self.collapsed_channels.binary_search(&channel_id).is_ok()
1421 }
1422
1423 fn leave_call(cx: &mut WindowContext) {
1424 ActiveCall::global(cx)
1425 .update(cx, |call, cx| call.hang_up(cx))
1426 .detach_and_log_err(cx);
1427 }
1428
1429 fn toggle_contact_finder(&mut self, cx: &mut ViewContext<Self>) {
1430 if let Some(workspace) = self.workspace.upgrade() {
1431 workspace.update(cx, |workspace, cx| {
1432 workspace.toggle_modal(cx, |cx| {
1433 let mut finder = ContactFinder::new(self.user_store.clone(), cx);
1434 finder.set_query(self.filter_editor.read(cx).text(cx), cx);
1435 finder
1436 });
1437 });
1438 }
1439 }
1440
1441 fn new_root_channel(&mut self, cx: &mut ViewContext<Self>) {
1442 self.channel_editing_state = Some(ChannelEditingState::Create {
1443 location: None,
1444 pending_name: None,
1445 });
1446 self.update_entries(false, cx);
1447 self.select_channel_editor();
1448 cx.focus_view(&self.channel_name_editor);
1449 cx.notify();
1450 }
1451
1452 fn select_channel_editor(&mut self) {
1453 self.selection = self.entries.iter().position(|entry| match entry {
1454 ListEntry::ChannelEditor { .. } => true,
1455 _ => false,
1456 });
1457 }
1458
1459 fn new_subchannel(&mut self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
1460 self.collapsed_channels
1461 .retain(|channel| *channel != channel_id);
1462 self.channel_editing_state = Some(ChannelEditingState::Create {
1463 location: Some(channel_id),
1464 pending_name: None,
1465 });
1466 self.update_entries(false, cx);
1467 self.select_channel_editor();
1468 cx.focus_view(&self.channel_name_editor);
1469 cx.notify();
1470 }
1471
1472 fn invite_members(&mut self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
1473 self.show_channel_modal(channel_id, channel_modal::Mode::InviteMembers, cx);
1474 }
1475
1476 fn manage_members(&mut self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
1477 self.show_channel_modal(channel_id, channel_modal::Mode::ManageMembers, cx);
1478 }
1479
1480 fn remove_selected_channel(&mut self, _: &Remove, cx: &mut ViewContext<Self>) {
1481 if let Some(channel) = self.selected_channel() {
1482 self.remove_channel(channel.id, cx)
1483 }
1484 }
1485
1486 fn rename_selected_channel(&mut self, _: &menu::SecondaryConfirm, cx: &mut ViewContext<Self>) {
1487 if let Some(channel) = self.selected_channel() {
1488 self.rename_channel(channel.id, cx);
1489 }
1490 }
1491
1492 fn rename_channel(&mut self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
1493 let channel_store = self.channel_store.read(cx);
1494 if !channel_store.is_channel_admin(channel_id) {
1495 return;
1496 }
1497 if let Some(channel) = channel_store.channel_for_id(channel_id).cloned() {
1498 self.channel_editing_state = Some(ChannelEditingState::Rename {
1499 location: channel_id,
1500 pending_name: None,
1501 });
1502 self.channel_name_editor.update(cx, |editor, cx| {
1503 editor.set_text(channel.name.clone(), cx);
1504 editor.select_all(&Default::default(), cx);
1505 });
1506 cx.focus_view(&self.channel_name_editor);
1507 self.update_entries(false, cx);
1508 self.select_channel_editor();
1509 }
1510 }
1511
1512 fn start_move_channel(&mut self, channel_id: ChannelId, _cx: &mut ViewContext<Self>) {
1513 self.channel_clipboard = Some(ChannelMoveClipboard { channel_id });
1514 }
1515
1516 fn start_move_selected_channel(&mut self, _: &StartMoveChannel, cx: &mut ViewContext<Self>) {
1517 if let Some(channel) = self.selected_channel() {
1518 self.start_move_channel(channel.id, cx);
1519 }
1520 }
1521
1522 fn move_channel_on_clipboard(
1523 &mut self,
1524 to_channel_id: ChannelId,
1525 cx: &mut ViewContext<CollabPanel>,
1526 ) {
1527 if let Some(clipboard) = self.channel_clipboard.take() {
1528 self.channel_store.update(cx, |channel_store, cx| {
1529 channel_store
1530 .move_channel(clipboard.channel_id, Some(to_channel_id), cx)
1531 .detach_and_log_err(cx)
1532 })
1533 }
1534 }
1535
1536 fn open_channel_notes(&mut self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
1537 if let Some(workspace) = self.workspace.upgrade() {
1538 ChannelView::open(channel_id, workspace, cx).detach();
1539 }
1540 }
1541
1542 fn show_inline_context_menu(&mut self, _: &menu::ShowContextMenu, cx: &mut ViewContext<Self>) {
1543 let Some(channel) = self.selected_channel() else {
1544 return;
1545 };
1546 let Some(bounds) = self
1547 .selection
1548 .and_then(|ix| self.list_state.bounds_for_item(ix))
1549 else {
1550 return;
1551 };
1552
1553 self.deploy_channel_context_menu(bounds.center(), channel.id, self.selection.unwrap(), cx);
1554 cx.stop_propagation();
1555 }
1556
1557 fn selected_channel(&self) -> Option<&Arc<Channel>> {
1558 self.selection
1559 .and_then(|ix| self.entries.get(ix))
1560 .and_then(|entry| match entry {
1561 ListEntry::Channel { channel, .. } => Some(channel),
1562 _ => None,
1563 })
1564 }
1565
1566 fn show_channel_modal(
1567 &mut self,
1568 channel_id: ChannelId,
1569 mode: channel_modal::Mode,
1570 cx: &mut ViewContext<Self>,
1571 ) {
1572 let workspace = self.workspace.clone();
1573 let user_store = self.user_store.clone();
1574 let channel_store = self.channel_store.clone();
1575 let members = self.channel_store.update(cx, |channel_store, cx| {
1576 channel_store.get_channel_member_details(channel_id, cx)
1577 });
1578
1579 cx.spawn(|_, mut cx| async move {
1580 let members = members.await?;
1581 workspace.update(&mut cx, |workspace, cx| {
1582 workspace.toggle_modal(cx, |cx| {
1583 ChannelModal::new(
1584 user_store.clone(),
1585 channel_store.clone(),
1586 channel_id,
1587 mode,
1588 members,
1589 cx,
1590 )
1591 });
1592 })
1593 })
1594 .detach();
1595 }
1596
1597 fn remove_channel(&mut self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
1598 let channel_store = self.channel_store.clone();
1599 if let Some(channel) = channel_store.read(cx).channel_for_id(channel_id) {
1600 let prompt_message = format!(
1601 "Are you sure you want to remove the channel \"{}\"?",
1602 channel.name
1603 );
1604 let answer = cx.prompt(PromptLevel::Warning, &prompt_message, &["Remove", "Cancel"]);
1605 cx.spawn(|this, mut cx| async move {
1606 if answer.await? == 0 {
1607 channel_store
1608 .update(&mut cx, |channels, _| channels.remove_channel(channel_id))?
1609 .await
1610 .notify_async_err(&mut cx);
1611 this.update(&mut cx, |_, cx| cx.focus_self()).ok();
1612 }
1613 anyhow::Ok(())
1614 })
1615 .detach();
1616 }
1617 }
1618
1619 fn remove_contact(&mut self, user_id: u64, github_login: &str, cx: &mut ViewContext<Self>) {
1620 let user_store = self.user_store.clone();
1621 let prompt_message = format!(
1622 "Are you sure you want to remove \"{}\" from your contacts?",
1623 github_login
1624 );
1625 let answer = cx.prompt(PromptLevel::Warning, &prompt_message, &["Remove", "Cancel"]);
1626 cx.spawn(|_, mut cx| async move {
1627 if answer.await? == 0 {
1628 user_store
1629 .update(&mut cx, |store, cx| store.remove_contact(user_id, cx))?
1630 .await
1631 .notify_async_err(&mut cx);
1632 }
1633 anyhow::Ok(())
1634 })
1635 .detach_and_log_err(cx);
1636 }
1637
1638 fn respond_to_contact_request(
1639 &mut self,
1640 user_id: u64,
1641 accept: bool,
1642 cx: &mut ViewContext<Self>,
1643 ) {
1644 self.user_store
1645 .update(cx, |store, cx| {
1646 store.respond_to_contact_request(user_id, accept, cx)
1647 })
1648 .detach_and_log_err(cx);
1649 }
1650
1651 fn respond_to_channel_invite(
1652 &mut self,
1653 channel_id: u64,
1654 accept: bool,
1655 cx: &mut ViewContext<Self>,
1656 ) {
1657 self.channel_store
1658 .update(cx, |store, cx| {
1659 store.respond_to_channel_invite(channel_id, accept, cx)
1660 })
1661 .detach();
1662 }
1663
1664 fn call(&mut self, recipient_user_id: u64, cx: &mut ViewContext<Self>) {
1665 ActiveCall::global(cx)
1666 .update(cx, |call, cx| {
1667 call.invite(recipient_user_id, Some(self.project.clone()), cx)
1668 })
1669 .detach_and_log_err(cx);
1670 }
1671
1672 fn join_channel(&self, channel_id: u64, cx: &mut ViewContext<Self>) {
1673 let Some(workspace) = self.workspace.upgrade() else {
1674 return;
1675 };
1676 let Some(handle) = cx.window_handle().downcast::<Workspace>() else {
1677 return;
1678 };
1679 workspace::join_channel(
1680 channel_id,
1681 workspace.read(cx).app_state().clone(),
1682 Some(handle),
1683 cx,
1684 )
1685 .detach_and_log_err(cx)
1686 }
1687
1688 fn join_channel_chat(&mut self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
1689 let Some(workspace) = self.workspace.upgrade() else {
1690 return;
1691 };
1692 cx.window_context().defer(move |cx| {
1693 workspace.update(cx, |workspace, cx| {
1694 if let Some(panel) = workspace.focus_panel::<ChatPanel>(cx) {
1695 panel.update(cx, |panel, cx| {
1696 panel
1697 .select_channel(channel_id, None, cx)
1698 .detach_and_log_err(cx);
1699 });
1700 }
1701 });
1702 });
1703 }
1704
1705 fn copy_channel_link(&mut self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
1706 let channel_store = self.channel_store.read(cx);
1707 let Some(channel) = channel_store.channel_for_id(channel_id) else {
1708 return;
1709 };
1710 let item = ClipboardItem::new(channel.link());
1711 cx.write_to_clipboard(item)
1712 }
1713
1714 fn render_signed_out(&mut self, cx: &mut ViewContext<Self>) -> Div {
1715 let collab_blurb = "Work with your team in realtime with collaborative editing, voice, shared notes and more.";
1716
1717 v_stack()
1718 .gap_6()
1719 .p_4()
1720 .child(Label::new(collab_blurb))
1721 .child(
1722 v_stack()
1723 .gap_2()
1724 .child(
1725 Button::new("sign_in", "Sign in")
1726 .icon_color(Color::Muted)
1727 .icon(Icon::Github)
1728 .icon_position(IconPosition::Start)
1729 .style(ButtonStyle::Filled)
1730 .full_width()
1731 .on_click(cx.listener(|this, _, cx| {
1732 let client = this.client.clone();
1733 cx.spawn(|_, mut cx| async move {
1734 client
1735 .authenticate_and_connect(true, &cx)
1736 .await
1737 .notify_async_err(&mut cx);
1738 })
1739 .detach()
1740 })),
1741 )
1742 .child(
1743 div().flex().w_full().items_center().child(
1744 Label::new("Sign in to enable collaboration.")
1745 .color(Color::Muted)
1746 .size(LabelSize::Small),
1747 ),
1748 ),
1749 )
1750 }
1751
1752 fn render_list_entry(&mut self, ix: usize, cx: &mut ViewContext<Self>) -> AnyElement {
1753 let entry = &self.entries[ix];
1754
1755 let is_selected = self.selection == Some(ix);
1756 match entry {
1757 ListEntry::Header(section) => {
1758 let is_collapsed = self.collapsed_sections.contains(section);
1759 self.render_header(*section, is_selected, is_collapsed, cx)
1760 .into_any_element()
1761 }
1762 ListEntry::Contact { contact, calling } => self
1763 .render_contact(contact, *calling, is_selected, cx)
1764 .into_any_element(),
1765 ListEntry::ContactPlaceholder => self
1766 .render_contact_placeholder(is_selected, cx)
1767 .into_any_element(),
1768 ListEntry::IncomingRequest(user) => self
1769 .render_contact_request(user, true, is_selected, cx)
1770 .into_any_element(),
1771 ListEntry::OutgoingRequest(user) => self
1772 .render_contact_request(user, false, is_selected, cx)
1773 .into_any_element(),
1774 ListEntry::Channel {
1775 channel,
1776 depth,
1777 has_children,
1778 } => self
1779 .render_channel(channel, *depth, *has_children, is_selected, ix, cx)
1780 .into_any_element(),
1781 ListEntry::ChannelEditor { depth } => {
1782 self.render_channel_editor(*depth, cx).into_any_element()
1783 }
1784 ListEntry::ChannelInvite(channel) => self
1785 .render_channel_invite(channel, is_selected, cx)
1786 .into_any_element(),
1787 ListEntry::CallParticipant {
1788 user,
1789 peer_id,
1790 is_pending,
1791 } => self
1792 .render_call_participant(user, *peer_id, *is_pending, is_selected, cx)
1793 .into_any_element(),
1794 ListEntry::ParticipantProject {
1795 project_id,
1796 worktree_root_names,
1797 host_user_id,
1798 is_last,
1799 } => self
1800 .render_participant_project(
1801 *project_id,
1802 &worktree_root_names,
1803 *host_user_id,
1804 *is_last,
1805 is_selected,
1806 cx,
1807 )
1808 .into_any_element(),
1809 ListEntry::ParticipantScreen { peer_id, is_last } => self
1810 .render_participant_screen(*peer_id, *is_last, is_selected, cx)
1811 .into_any_element(),
1812 ListEntry::GuestCount {
1813 count,
1814 has_visible_participants,
1815 } => self
1816 .render_guest_count(*count, *has_visible_participants, is_selected, cx)
1817 .into_any_element(),
1818 ListEntry::ChannelNotes { channel_id } => self
1819 .render_channel_notes(*channel_id, is_selected, cx)
1820 .into_any_element(),
1821 ListEntry::ChannelChat { channel_id } => self
1822 .render_channel_chat(*channel_id, is_selected, cx)
1823 .into_any_element(),
1824 }
1825 }
1826
1827 fn render_signed_in(&mut self, cx: &mut ViewContext<Self>) -> Div {
1828 v_stack()
1829 .size_full()
1830 .child(list(self.list_state.clone()).full())
1831 .child(
1832 v_stack()
1833 .child(div().mx_2().border_primary(cx).border_t())
1834 .child(
1835 v_stack()
1836 .p_2()
1837 .child(self.render_filter_input(&self.filter_editor, cx)),
1838 ),
1839 )
1840 }
1841
1842 fn render_filter_input(
1843 &self,
1844 editor: &View<Editor>,
1845 cx: &mut ViewContext<Self>,
1846 ) -> impl IntoElement {
1847 let settings = ThemeSettings::get_global(cx);
1848 let text_style = TextStyle {
1849 color: if editor.read(cx).read_only(cx) {
1850 cx.theme().colors().text_disabled
1851 } else {
1852 cx.theme().colors().text
1853 },
1854 font_family: settings.ui_font.family.clone(),
1855 font_features: settings.ui_font.features,
1856 font_size: rems(0.875).into(),
1857 font_weight: FontWeight::NORMAL,
1858 font_style: FontStyle::Normal,
1859 line_height: relative(1.3).into(),
1860 background_color: None,
1861 underline: None,
1862 white_space: WhiteSpace::Normal,
1863 };
1864
1865 EditorElement::new(
1866 editor,
1867 EditorStyle {
1868 local_player: cx.theme().players().local(),
1869 text: text_style,
1870 ..Default::default()
1871 },
1872 )
1873 }
1874
1875 fn render_header(
1876 &self,
1877 section: Section,
1878 is_selected: bool,
1879 is_collapsed: bool,
1880 cx: &ViewContext<Self>,
1881 ) -> impl IntoElement {
1882 let mut channel_link = None;
1883 let mut channel_tooltip_text = None;
1884 let mut channel_icon = None;
1885 // let mut is_dragged_over = false;
1886
1887 let text = match section {
1888 Section::ActiveCall => {
1889 let channel_name = maybe!({
1890 let channel_id = ActiveCall::global(cx).read(cx).channel_id(cx)?;
1891
1892 let channel = self.channel_store.read(cx).channel_for_id(channel_id)?;
1893
1894 channel_link = Some(channel.link());
1895 (channel_icon, channel_tooltip_text) = match channel.visibility {
1896 proto::ChannelVisibility::Public => {
1897 (Some("icons/public.svg"), Some("Copy public channel link."))
1898 }
1899 proto::ChannelVisibility::Members => {
1900 (Some("icons/hash.svg"), Some("Copy private channel link."))
1901 }
1902 };
1903
1904 Some(channel.name.as_ref())
1905 });
1906
1907 if let Some(name) = channel_name {
1908 SharedString::from(format!("{}", name))
1909 } else {
1910 SharedString::from("Current Call")
1911 }
1912 }
1913 Section::ContactRequests => SharedString::from("Requests"),
1914 Section::Contacts => SharedString::from("Contacts"),
1915 Section::Channels => SharedString::from("Channels"),
1916 Section::ChannelInvites => SharedString::from("Invites"),
1917 Section::Online => SharedString::from("Online"),
1918 Section::Offline => SharedString::from("Offline"),
1919 };
1920
1921 let button = match section {
1922 Section::ActiveCall => channel_link.map(|channel_link| {
1923 let channel_link_copy = channel_link.clone();
1924 IconButton::new("channel-link", Icon::Copy)
1925 .icon_size(IconSize::Small)
1926 .size(ButtonSize::None)
1927 .visible_on_hover("section-header")
1928 .on_click(move |_, cx| {
1929 let item = ClipboardItem::new(channel_link_copy.clone());
1930 cx.write_to_clipboard(item)
1931 })
1932 .tooltip(|cx| Tooltip::text("Copy channel link", cx))
1933 .into_any_element()
1934 }),
1935 Section::Contacts => Some(
1936 IconButton::new("add-contact", Icon::Plus)
1937 .on_click(cx.listener(|this, _, cx| this.toggle_contact_finder(cx)))
1938 .tooltip(|cx| Tooltip::text("Search for new contact", cx))
1939 .into_any_element(),
1940 ),
1941 Section::Channels => Some(
1942 IconButton::new("add-channel", Icon::Plus)
1943 .on_click(cx.listener(|this, _, cx| this.new_root_channel(cx)))
1944 .tooltip(|cx| Tooltip::text("Create a channel", cx))
1945 .into_any_element(),
1946 ),
1947 _ => None,
1948 };
1949
1950 let can_collapse = match section {
1951 Section::ActiveCall | Section::Channels | Section::Contacts => false,
1952 Section::ChannelInvites
1953 | Section::ContactRequests
1954 | Section::Online
1955 | Section::Offline => true,
1956 };
1957
1958 h_stack()
1959 .w_full()
1960 .group("section-header")
1961 .child(
1962 ListHeader::new(text)
1963 .when(can_collapse, |header| {
1964 header.toggle(Some(!is_collapsed)).on_toggle(cx.listener(
1965 move |this, _, cx| {
1966 this.toggle_section_expanded(section, cx);
1967 },
1968 ))
1969 })
1970 .inset(true)
1971 .end_slot::<AnyElement>(button)
1972 .selected(is_selected),
1973 )
1974 .when(section == Section::Channels, |el| {
1975 el.drag_over::<Channel>(|style| style.bg(cx.theme().colors().ghost_element_hover))
1976 .on_drop(cx.listener(move |this, dragged_channel: &Channel, cx| {
1977 this.channel_store
1978 .update(cx, |channel_store, cx| {
1979 channel_store.move_channel(dragged_channel.id, None, cx)
1980 })
1981 .detach_and_log_err(cx)
1982 }))
1983 })
1984 }
1985
1986 fn render_contact(
1987 &self,
1988 contact: &Contact,
1989 calling: bool,
1990 is_selected: bool,
1991 cx: &mut ViewContext<Self>,
1992 ) -> impl IntoElement {
1993 let online = contact.online;
1994 let busy = contact.busy || calling;
1995 let user_id = contact.user.id;
1996 let github_login = SharedString::from(contact.user.github_login.clone());
1997 let item =
1998 ListItem::new(github_login.clone())
1999 .indent_level(1)
2000 .indent_step_size(px(20.))
2001 .selected(is_selected)
2002 .on_click(cx.listener(move |this, _, cx| this.call(user_id, cx)))
2003 .child(
2004 h_stack()
2005 .w_full()
2006 .justify_between()
2007 .child(Label::new(github_login.clone()))
2008 .when(calling, |el| {
2009 el.child(Label::new("Calling").color(Color::Muted))
2010 })
2011 .when(!calling, |el| {
2012 el.child(
2013 IconButton::new("remove_contact", Icon::Close)
2014 .icon_color(Color::Muted)
2015 .visible_on_hover("")
2016 .tooltip(|cx| Tooltip::text("Remove Contact", cx))
2017 .on_click(cx.listener({
2018 let github_login = github_login.clone();
2019 move |this, _, cx| {
2020 this.remove_contact(user_id, &github_login, cx);
2021 }
2022 })),
2023 )
2024 }),
2025 )
2026 .start_slot(
2027 // todo!() handle contacts with no avatar
2028 Avatar::new(contact.user.avatar_uri.clone())
2029 .availability_indicator(if online { Some(!busy) } else { None }),
2030 )
2031 .when(online && !busy, |el| {
2032 el.on_click(cx.listener(move |this, _, cx| this.call(user_id, cx)))
2033 });
2034
2035 div()
2036 .id(github_login.clone())
2037 .group("")
2038 .child(item)
2039 .tooltip(move |cx| {
2040 let text = if !online {
2041 format!(" {} is offline", &github_login)
2042 } else if busy {
2043 format!(" {} is on a call", &github_login)
2044 } else {
2045 let room = ActiveCall::global(cx).read(cx).room();
2046 if room.is_some() {
2047 format!("Invite {} to join call", &github_login)
2048 } else {
2049 format!("Call {}", &github_login)
2050 }
2051 };
2052 Tooltip::text(text, cx)
2053 })
2054 }
2055
2056 fn render_contact_request(
2057 &self,
2058 user: &Arc<User>,
2059 is_incoming: bool,
2060 is_selected: bool,
2061 cx: &mut ViewContext<Self>,
2062 ) -> impl IntoElement {
2063 let github_login = SharedString::from(user.github_login.clone());
2064 let user_id = user.id;
2065 let is_response_pending = self.user_store.read(cx).is_contact_request_pending(&user);
2066 let color = if is_response_pending {
2067 Color::Muted
2068 } else {
2069 Color::Default
2070 };
2071
2072 let controls = if is_incoming {
2073 vec![
2074 IconButton::new("decline-contact", Icon::Close)
2075 .on_click(cx.listener(move |this, _, cx| {
2076 this.respond_to_contact_request(user_id, false, cx);
2077 }))
2078 .icon_color(color)
2079 .tooltip(|cx| Tooltip::text("Decline invite", cx)),
2080 IconButton::new("accept-contact", Icon::Check)
2081 .on_click(cx.listener(move |this, _, cx| {
2082 this.respond_to_contact_request(user_id, true, cx);
2083 }))
2084 .icon_color(color)
2085 .tooltip(|cx| Tooltip::text("Accept invite", cx)),
2086 ]
2087 } else {
2088 let github_login = github_login.clone();
2089 vec![IconButton::new("remove_contact", Icon::Close)
2090 .on_click(cx.listener(move |this, _, cx| {
2091 this.remove_contact(user_id, &github_login, cx);
2092 }))
2093 .icon_color(color)
2094 .tooltip(|cx| Tooltip::text("Cancel invite", cx))]
2095 };
2096
2097 ListItem::new(github_login.clone())
2098 .indent_level(1)
2099 .indent_step_size(px(20.))
2100 .selected(is_selected)
2101 .child(
2102 h_stack()
2103 .w_full()
2104 .justify_between()
2105 .child(Label::new(github_login.clone()))
2106 .child(h_stack().children(controls)),
2107 )
2108 .start_slot(Avatar::new(user.avatar_uri.clone()))
2109 }
2110
2111 fn render_channel_invite(
2112 &self,
2113 channel: &Arc<Channel>,
2114 is_selected: bool,
2115 cx: &mut ViewContext<Self>,
2116 ) -> ListItem {
2117 let channel_id = channel.id;
2118 let response_is_pending = self
2119 .channel_store
2120 .read(cx)
2121 .has_pending_channel_invite_response(&channel);
2122 let color = if response_is_pending {
2123 Color::Muted
2124 } else {
2125 Color::Default
2126 };
2127
2128 let controls = [
2129 IconButton::new("reject-invite", Icon::Close)
2130 .on_click(cx.listener(move |this, _, cx| {
2131 this.respond_to_channel_invite(channel_id, false, cx);
2132 }))
2133 .icon_color(color)
2134 .tooltip(|cx| Tooltip::text("Decline invite", cx)),
2135 IconButton::new("accept-invite", Icon::Check)
2136 .on_click(cx.listener(move |this, _, cx| {
2137 this.respond_to_channel_invite(channel_id, true, cx);
2138 }))
2139 .icon_color(color)
2140 .tooltip(|cx| Tooltip::text("Accept invite", cx)),
2141 ];
2142
2143 ListItem::new(("channel-invite", channel.id as usize))
2144 .selected(is_selected)
2145 .child(
2146 h_stack()
2147 .w_full()
2148 .justify_between()
2149 .child(Label::new(channel.name.clone()))
2150 .child(h_stack().children(controls)),
2151 )
2152 .start_slot(
2153 IconElement::new(Icon::Hash)
2154 .size(IconSize::Small)
2155 .color(Color::Muted),
2156 )
2157 }
2158
2159 fn render_contact_placeholder(
2160 &self,
2161 is_selected: bool,
2162 cx: &mut ViewContext<Self>,
2163 ) -> ListItem {
2164 ListItem::new("contact-placeholder")
2165 .child(IconElement::new(Icon::Plus))
2166 .child(Label::new("Add a Contact"))
2167 .selected(is_selected)
2168 .on_click(cx.listener(|this, _, cx| this.toggle_contact_finder(cx)))
2169 }
2170
2171 fn render_channel(
2172 &self,
2173 channel: &Channel,
2174 depth: usize,
2175 has_children: bool,
2176 is_selected: bool,
2177 ix: usize,
2178 cx: &mut ViewContext<Self>,
2179 ) -> impl IntoElement {
2180 let channel_id = channel.id;
2181
2182 let is_active = maybe!({
2183 let call_channel = ActiveCall::global(cx)
2184 .read(cx)
2185 .room()?
2186 .read(cx)
2187 .channel_id()?;
2188 Some(call_channel == channel_id)
2189 })
2190 .unwrap_or(false);
2191 let is_public = self
2192 .channel_store
2193 .read(cx)
2194 .channel_for_id(channel_id)
2195 .map(|channel| channel.visibility)
2196 == Some(proto::ChannelVisibility::Public);
2197 let disclosed =
2198 has_children.then(|| !self.collapsed_channels.binary_search(&channel.id).is_ok());
2199
2200 let has_messages_notification = channel.unseen_message_id.is_some();
2201 let has_notes_notification = channel.unseen_note_version.is_some();
2202
2203 const FACEPILE_LIMIT: usize = 3;
2204 let participants = self.channel_store.read(cx).channel_participants(channel_id);
2205
2206 let face_pile = if !participants.is_empty() {
2207 let extra_count = participants.len().saturating_sub(FACEPILE_LIMIT);
2208 let result = FacePile {
2209 faces: participants
2210 .iter()
2211 .map(|user| Avatar::new(user.avatar_uri.clone()).into_any_element())
2212 .take(FACEPILE_LIMIT)
2213 .chain(if extra_count > 0 {
2214 Some(
2215 div()
2216 .ml_1()
2217 .child(Label::new(format!("+{extra_count}")))
2218 .into_any_element(),
2219 )
2220 } else {
2221 None
2222 })
2223 .collect::<SmallVec<_>>(),
2224 };
2225
2226 Some(result)
2227 } else {
2228 None
2229 };
2230
2231 let button_container = |cx: &mut ViewContext<Self>| {
2232 h_stack()
2233 .absolute()
2234 // We're using a negative coordinate for the right anchor to
2235 // counteract the padding of the `ListItem`.
2236 //
2237 // This prevents a gap from showing up between the background
2238 // of this element and the edge of the collab panel.
2239 .right(rems(-0.5))
2240 // HACK: Without this the channel name clips on top of the icons, but I'm not sure why.
2241 .z_index(10)
2242 .bg(cx.theme().colors().panel_background)
2243 .when(is_selected || is_active, |this| {
2244 this.bg(cx.theme().colors().ghost_element_selected)
2245 })
2246 };
2247
2248 let messages_button = |cx: &mut ViewContext<Self>| {
2249 IconButton::new("channel_chat", Icon::MessageBubbles)
2250 .icon_size(IconSize::Small)
2251 .icon_color(if has_messages_notification {
2252 Color::Default
2253 } else {
2254 Color::Muted
2255 })
2256 .on_click(cx.listener(move |this, _, cx| this.join_channel_chat(channel_id, cx)))
2257 .tooltip(|cx| Tooltip::text("Open channel chat", cx))
2258 };
2259
2260 let notes_button = |cx: &mut ViewContext<Self>| {
2261 IconButton::new("channel_notes", Icon::File)
2262 .icon_size(IconSize::Small)
2263 .icon_color(if has_notes_notification {
2264 Color::Default
2265 } else {
2266 Color::Muted
2267 })
2268 .on_click(cx.listener(move |this, _, cx| this.open_channel_notes(channel_id, cx)))
2269 .tooltip(|cx| Tooltip::text("Open channel notes", cx))
2270 };
2271
2272 let width = self.width.unwrap_or(px(240.));
2273
2274 div()
2275 .id(channel_id as usize)
2276 .group("")
2277 .flex()
2278 .w_full()
2279 .on_drag(channel.clone(), move |channel, cx| {
2280 cx.new_view(|_| DraggedChannelView {
2281 channel: channel.clone(),
2282 width,
2283 })
2284 })
2285 .drag_over::<Channel>(|style| style.bg(cx.theme().colors().ghost_element_hover))
2286 .on_drop(cx.listener(move |this, dragged_channel: &Channel, cx| {
2287 this.channel_store
2288 .update(cx, |channel_store, cx| {
2289 channel_store.move_channel(dragged_channel.id, Some(channel_id), cx)
2290 })
2291 .detach_and_log_err(cx)
2292 }))
2293 .child(
2294 ListItem::new(channel_id as usize)
2295 // Add one level of depth for the disclosure arrow.
2296 .indent_level(depth + 1)
2297 .indent_step_size(px(20.))
2298 .selected(is_selected || is_active)
2299 .toggle(disclosed)
2300 .on_toggle(
2301 cx.listener(move |this, _, cx| {
2302 this.toggle_channel_collapsed(channel_id, cx)
2303 }),
2304 )
2305 .on_click(cx.listener(move |this, _, cx| {
2306 if is_active {
2307 this.open_channel_notes(channel_id, cx)
2308 } else {
2309 this.join_channel(channel_id, cx)
2310 }
2311 }))
2312 .on_secondary_mouse_down(cx.listener(
2313 move |this, event: &MouseDownEvent, cx| {
2314 this.deploy_channel_context_menu(event.position, channel_id, ix, cx)
2315 },
2316 ))
2317 .start_slot(
2318 IconElement::new(if is_public { Icon::Public } else { Icon::Hash })
2319 .size(IconSize::Small)
2320 .color(Color::Muted),
2321 )
2322 .child(
2323 h_stack()
2324 .id(channel_id as usize)
2325 // HACK: This is a dirty hack to help with the positioning of the button container.
2326 //
2327 // We're using a pixel width for the elements but then allowing the contents to
2328 // overflow. This means that the label and facepile will be shown, but will not
2329 // push the button container off the edge of the panel.
2330 .w_px()
2331 .child(Label::new(channel.name.clone()))
2332 .children(face_pile.map(|face_pile| face_pile.render(cx))),
2333 )
2334 .end_slot::<Div>(
2335 // If we have a notification for either button, we want to show the corresponding
2336 // button(s) as indicators.
2337 if has_messages_notification || has_notes_notification {
2338 Some(
2339 button_container(cx).child(
2340 h_stack()
2341 .px_1()
2342 .children(
2343 // We only want to render the messages button if there are unseen messages.
2344 // This way we don't take up any space that might overlap the channel name
2345 // when there are no notifications.
2346 has_messages_notification.then(|| messages_button(cx)),
2347 )
2348 .child(
2349 // We always want the notes button to take up space to prevent layout
2350 // shift when hovering over the channel.
2351 // However, if there are is no notes notification we just show an empty slot.
2352 notes_button(cx)
2353 .when(!has_notes_notification, |this| {
2354 this.visible_on_hover("")
2355 }),
2356 ),
2357 ),
2358 )
2359 } else {
2360 None
2361 },
2362 )
2363 .end_hover_slot(
2364 // When we hover the channel entry we want to always show both buttons.
2365 button_container(cx).child(
2366 h_stack()
2367 .px_1()
2368 // The element hover background has a slight transparency to it, so we
2369 // need to apply it to the inner element so that it blends with the solid
2370 // background color of the absolutely-positioned element.
2371 .group_hover("", |style| {
2372 style.bg(cx.theme().colors().ghost_element_hover)
2373 })
2374 .child(messages_button(cx))
2375 .child(notes_button(cx)),
2376 ),
2377 ),
2378 )
2379 .tooltip(|cx| Tooltip::text("Join channel", cx))
2380 }
2381
2382 fn render_channel_editor(&self, depth: usize, _cx: &mut ViewContext<Self>) -> impl IntoElement {
2383 let item = ListItem::new("channel-editor")
2384 .inset(false)
2385 // Add one level of depth for the disclosure arrow.
2386 .indent_level(depth + 1)
2387 .indent_step_size(px(20.))
2388 .start_slot(
2389 IconElement::new(Icon::Hash)
2390 .size(IconSize::Small)
2391 .color(Color::Muted),
2392 );
2393
2394 if let Some(pending_name) = self
2395 .channel_editing_state
2396 .as_ref()
2397 .and_then(|state| state.pending_name())
2398 {
2399 item.child(Label::new(pending_name))
2400 } else {
2401 item.child(self.channel_name_editor.clone())
2402 }
2403 }
2404}
2405
2406fn render_tree_branch(is_last: bool, overdraw: bool, cx: &mut WindowContext) -> impl IntoElement {
2407 let rem_size = cx.rem_size();
2408 let line_height = cx.text_style().line_height_in_pixels(rem_size);
2409 let width = rem_size * 1.5;
2410 let thickness = px(1.);
2411 let color = cx.theme().colors().text;
2412
2413 canvas(move |bounds, cx| {
2414 let start_x = (bounds.left() + bounds.right() - thickness) / 2.;
2415 let start_y = (bounds.top() + bounds.bottom() - thickness) / 2.;
2416 let right = bounds.right();
2417 let top = bounds.top();
2418
2419 cx.paint_quad(fill(
2420 Bounds::from_corners(
2421 point(start_x, top),
2422 point(
2423 start_x + thickness,
2424 if is_last {
2425 start_y
2426 } else {
2427 bounds.bottom() + if overdraw { px(1.) } else { px(0.) }
2428 },
2429 ),
2430 ),
2431 color,
2432 ));
2433 cx.paint_quad(fill(
2434 Bounds::from_corners(point(start_x, start_y), point(right, start_y + thickness)),
2435 color,
2436 ));
2437 })
2438 .w(width)
2439 .h(line_height)
2440}
2441
2442impl Render for CollabPanel {
2443 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
2444 v_stack()
2445 .key_context("CollabPanel")
2446 .on_action(cx.listener(CollabPanel::cancel))
2447 .on_action(cx.listener(CollabPanel::select_next))
2448 .on_action(cx.listener(CollabPanel::select_prev))
2449 .on_action(cx.listener(CollabPanel::confirm))
2450 .on_action(cx.listener(CollabPanel::insert_space))
2451 .on_action(cx.listener(CollabPanel::remove_selected_channel))
2452 .on_action(cx.listener(CollabPanel::show_inline_context_menu))
2453 .on_action(cx.listener(CollabPanel::rename_selected_channel))
2454 .on_action(cx.listener(CollabPanel::collapse_selected_channel))
2455 .on_action(cx.listener(CollabPanel::expand_selected_channel))
2456 .on_action(cx.listener(CollabPanel::start_move_selected_channel))
2457 .track_focus(&self.focus_handle)
2458 .size_full()
2459 .child(if self.user_store.read(cx).current_user().is_none() {
2460 self.render_signed_out(cx)
2461 } else {
2462 self.render_signed_in(cx)
2463 })
2464 .children(self.context_menu.as_ref().map(|(menu, position, _)| {
2465 overlay()
2466 .position(*position)
2467 .anchor(gpui::AnchorCorner::TopLeft)
2468 .child(menu.clone())
2469 }))
2470 }
2471}
2472
2473impl EventEmitter<PanelEvent> for CollabPanel {}
2474
2475impl Panel for CollabPanel {
2476 fn position(&self, cx: &gpui::WindowContext) -> DockPosition {
2477 CollaborationPanelSettings::get_global(cx).dock
2478 }
2479
2480 fn position_is_valid(&self, position: DockPosition) -> bool {
2481 matches!(position, DockPosition::Left | DockPosition::Right)
2482 }
2483
2484 fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
2485 settings::update_settings_file::<CollaborationPanelSettings>(
2486 self.fs.clone(),
2487 cx,
2488 move |settings| settings.dock = Some(position),
2489 );
2490 }
2491
2492 fn size(&self, cx: &gpui::WindowContext) -> Pixels {
2493 self.width
2494 .unwrap_or_else(|| CollaborationPanelSettings::get_global(cx).default_width)
2495 }
2496
2497 fn set_size(&mut self, size: Option<Pixels>, cx: &mut ViewContext<Self>) {
2498 self.width = size;
2499 self.serialize(cx);
2500 cx.notify();
2501 }
2502
2503 fn icon(&self, cx: &gpui::WindowContext) -> Option<ui::Icon> {
2504 CollaborationPanelSettings::get_global(cx)
2505 .button
2506 .then(|| ui::Icon::Collab)
2507 }
2508
2509 fn icon_tooltip(&self, _cx: &WindowContext) -> Option<&'static str> {
2510 Some("Collab Panel")
2511 }
2512
2513 fn toggle_action(&self) -> Box<dyn gpui::Action> {
2514 Box::new(ToggleFocus)
2515 }
2516
2517 fn persistent_name() -> &'static str {
2518 "CollabPanel"
2519 }
2520}
2521
2522impl FocusableView for CollabPanel {
2523 fn focus_handle(&self, cx: &AppContext) -> gpui::FocusHandle {
2524 self.filter_editor.focus_handle(cx).clone()
2525 }
2526}
2527
2528impl PartialEq for ListEntry {
2529 fn eq(&self, other: &Self) -> bool {
2530 match self {
2531 ListEntry::Header(section_1) => {
2532 if let ListEntry::Header(section_2) = other {
2533 return section_1 == section_2;
2534 }
2535 }
2536 ListEntry::CallParticipant { user: user_1, .. } => {
2537 if let ListEntry::CallParticipant { user: user_2, .. } = other {
2538 return user_1.id == user_2.id;
2539 }
2540 }
2541 ListEntry::ParticipantProject {
2542 project_id: project_id_1,
2543 ..
2544 } => {
2545 if let ListEntry::ParticipantProject {
2546 project_id: project_id_2,
2547 ..
2548 } = other
2549 {
2550 return project_id_1 == project_id_2;
2551 }
2552 }
2553 ListEntry::ParticipantScreen {
2554 peer_id: peer_id_1, ..
2555 } => {
2556 if let ListEntry::ParticipantScreen {
2557 peer_id: peer_id_2, ..
2558 } = other
2559 {
2560 return peer_id_1 == peer_id_2;
2561 }
2562 }
2563 ListEntry::Channel {
2564 channel: channel_1, ..
2565 } => {
2566 if let ListEntry::Channel {
2567 channel: channel_2, ..
2568 } = other
2569 {
2570 return channel_1.id == channel_2.id;
2571 }
2572 }
2573 ListEntry::ChannelNotes { channel_id } => {
2574 if let ListEntry::ChannelNotes {
2575 channel_id: other_id,
2576 } = other
2577 {
2578 return channel_id == other_id;
2579 }
2580 }
2581 ListEntry::ChannelChat { channel_id } => {
2582 if let ListEntry::ChannelChat {
2583 channel_id: other_id,
2584 } = other
2585 {
2586 return channel_id == other_id;
2587 }
2588 }
2589 ListEntry::ChannelInvite(channel_1) => {
2590 if let ListEntry::ChannelInvite(channel_2) = other {
2591 return channel_1.id == channel_2.id;
2592 }
2593 }
2594 ListEntry::IncomingRequest(user_1) => {
2595 if let ListEntry::IncomingRequest(user_2) = other {
2596 return user_1.id == user_2.id;
2597 }
2598 }
2599 ListEntry::OutgoingRequest(user_1) => {
2600 if let ListEntry::OutgoingRequest(user_2) = other {
2601 return user_1.id == user_2.id;
2602 }
2603 }
2604 ListEntry::Contact {
2605 contact: contact_1, ..
2606 } => {
2607 if let ListEntry::Contact {
2608 contact: contact_2, ..
2609 } = other
2610 {
2611 return contact_1.user.id == contact_2.user.id;
2612 }
2613 }
2614 ListEntry::ChannelEditor { depth } => {
2615 if let ListEntry::ChannelEditor { depth: other_depth } = other {
2616 return depth == other_depth;
2617 }
2618 }
2619 ListEntry::ContactPlaceholder => {
2620 if let ListEntry::ContactPlaceholder = other {
2621 return true;
2622 }
2623 }
2624 ListEntry::GuestCount { .. } => {
2625 if let ListEntry::GuestCount { .. } = other {
2626 return true;
2627 }
2628 }
2629 }
2630 false
2631 }
2632}
2633
2634struct DraggedChannelView {
2635 channel: Channel,
2636 width: Pixels,
2637}
2638
2639impl Render for DraggedChannelView {
2640 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl Element {
2641 let ui_font = ThemeSettings::get_global(cx).ui_font.family.clone();
2642 h_stack()
2643 .font(ui_font)
2644 .bg(cx.theme().colors().background)
2645 .w(self.width)
2646 .p_1()
2647 .gap_1()
2648 .child(
2649 IconElement::new(
2650 if self.channel.visibility == proto::ChannelVisibility::Public {
2651 Icon::Public
2652 } else {
2653 Icon::Hash
2654 },
2655 )
2656 .size(IconSize::Small)
2657 .color(Color::Muted),
2658 )
2659 .child(Label::new(self.channel.name.clone()))
2660 }
2661}