1mod channel_modal;
2mod contact_finder;
3
4use self::channel_modal::ChannelModal;
5use crate::{CollaborationPanelSettings, channel_view::ChannelView};
6use anyhow::Context as _;
7use call::ActiveCall;
8use channel::{Channel, ChannelEvent, ChannelStore};
9use client::{ChannelId, Client, Contact, User, UserStore};
10use contact_finder::ContactFinder;
11use db::kvp::KEY_VALUE_STORE;
12use editor::{Editor, EditorElement, EditorStyle};
13use fuzzy::{StringMatchCandidate, match_strings};
14use gpui::{
15 AnyElement, App, AsyncWindowContext, Bounds, ClickEvent, ClipboardItem, Context, DismissEvent,
16 Div, Entity, EventEmitter, FocusHandle, Focusable, FontStyle, InteractiveElement, IntoElement,
17 KeyContext, ListOffset, ListState, MouseDownEvent, ParentElement, Pixels, Point, PromptLevel,
18 Render, SharedString, Styled, Subscription, Task, TextStyle, WeakEntity, Window, actions,
19 anchored, canvas, deferred, div, fill, list, point, prelude::*, px,
20};
21use menu::{Cancel, Confirm, SecondaryConfirm, SelectNext, SelectPrevious};
22use project::{Fs, Project};
23use rpc::{
24 ErrorCode, ErrorExt,
25 proto::{self, ChannelVisibility, PeerId},
26};
27use serde_derive::{Deserialize, Serialize};
28use settings::Settings;
29use smallvec::SmallVec;
30use std::{mem, sync::Arc};
31use theme::{ActiveTheme, ThemeSettings};
32use ui::{
33 Avatar, AvatarAvailabilityIndicator, Button, Color, ContextMenu, Facepile, Icon, IconButton,
34 IconName, IconSize, Indicator, Label, ListHeader, ListItem, Tooltip, prelude::*,
35 tooltip_container,
36};
37use util::{ResultExt, TryFutureExt, maybe};
38use workspace::{
39 Deafen, LeaveCall, Mute, OpenChannelNotes, ScreenShare, ShareProject, Workspace,
40 dock::{DockPosition, Panel, PanelEvent},
41 notifications::{DetachAndPromptErr, NotifyResultExt},
42};
43
44actions!(
45 collab_panel,
46 [
47 /// Toggles focus on the collaboration panel.
48 ToggleFocus,
49 /// Removes the selected channel or contact.
50 Remove,
51 /// Opens the context menu for the selected item.
52 Secondary,
53 /// Collapses the selected channel in the tree view.
54 CollapseSelectedChannel,
55 /// Expands the selected channel in the tree view.
56 ExpandSelectedChannel,
57 /// Starts moving a channel to a new location.
58 StartMoveChannel,
59 /// Moves the selected item to the current location.
60 MoveSelected,
61 /// Inserts a space character in the filter input.
62 InsertSpace,
63 /// Moves the selected channel up in the list.
64 MoveChannelUp,
65 /// Moves the selected channel down in the list.
66 MoveChannelDown,
67 ]
68);
69
70#[derive(Debug, Copy, Clone, PartialEq, Eq)]
71struct ChannelMoveClipboard {
72 channel_id: ChannelId,
73}
74
75const COLLABORATION_PANEL_KEY: &str = "CollaborationPanel";
76
77pub fn init(cx: &mut App) {
78 cx.observe_new(|workspace: &mut Workspace, _, _| {
79 workspace.register_action(|workspace, _: &ToggleFocus, window, cx| {
80 workspace.toggle_panel_focus::<CollabPanel>(window, cx);
81 if let Some(collab_panel) = workspace.panel::<CollabPanel>(cx) {
82 collab_panel.update(cx, |panel, cx| {
83 panel.filter_editor.update(cx, |editor, cx| {
84 if editor.snapshot(window, cx).is_focused() {
85 editor.select_all(&Default::default(), window, cx);
86 }
87 });
88 })
89 }
90 });
91 workspace.register_action(|_, _: &OpenChannelNotes, window, cx| {
92 let channel_id = ActiveCall::global(cx)
93 .read(cx)
94 .room()
95 .and_then(|room| room.read(cx).channel_id());
96
97 if let Some(channel_id) = channel_id {
98 let workspace = cx.entity();
99 window.defer(cx, move |window, cx| {
100 ChannelView::open(channel_id, None, workspace, window, cx)
101 .detach_and_log_err(cx)
102 });
103 }
104 });
105 // TODO: make it possible to bind this one to a held key for push to talk?
106 // how to make "toggle_on_modifiers_press" contextual?
107 workspace.register_action(|_, _: &Mute, window, cx| {
108 let room = ActiveCall::global(cx).read(cx).room().cloned();
109 if let Some(room) = room {
110 window.defer(cx, move |_window, cx| {
111 room.update(cx, |room, cx| room.toggle_mute(cx))
112 });
113 }
114 });
115 workspace.register_action(|_, _: &Deafen, window, cx| {
116 let room = ActiveCall::global(cx).read(cx).room().cloned();
117 if let Some(room) = room {
118 window.defer(cx, move |_window, cx| {
119 room.update(cx, |room, cx| room.toggle_deafen(cx))
120 });
121 }
122 });
123 workspace.register_action(|_, _: &LeaveCall, window, cx| {
124 CollabPanel::leave_call(window, cx);
125 });
126 workspace.register_action(|workspace, _: &ShareProject, window, cx| {
127 let project = workspace.project().clone();
128 println!("{project:?}");
129 window.defer(cx, move |_window, cx| {
130 ActiveCall::global(cx).update(cx, move |call, cx| {
131 if let Some(room) = call.room() {
132 println!("{room:?}");
133 if room.read(cx).is_sharing_project() {
134 call.unshare_project(project, cx).ok();
135 } else {
136 call.share_project(project, cx).detach_and_log_err(cx);
137 }
138 }
139 });
140 });
141 });
142 workspace.register_action(|_, _: &ScreenShare, window, cx| {
143 let room = ActiveCall::global(cx).read(cx).room().cloned();
144 if let Some(room) = room {
145 window.defer(cx, move |_window, cx| {
146 room.update(cx, |room, cx| {
147 if room.is_sharing_screen() {
148 room.unshare_screen(true, cx).ok();
149 } else {
150 let sources = cx.screen_capture_sources();
151
152 cx.spawn(async move |room, cx| {
153 let sources = sources.await??;
154 let first = sources.into_iter().next();
155 if let Some(first) = first {
156 room.update(cx, |room, cx| room.share_screen(first, cx))?
157 .await
158 } else {
159 Ok(())
160 }
161 })
162 .detach_and_log_err(cx);
163 };
164 });
165 });
166 }
167 });
168 })
169 .detach();
170}
171
172#[derive(Debug)]
173pub enum ChannelEditingState {
174 Create {
175 location: Option<ChannelId>,
176 pending_name: Option<String>,
177 },
178 Rename {
179 location: ChannelId,
180 pending_name: Option<String>,
181 },
182}
183
184impl ChannelEditingState {
185 fn pending_name(&self) -> Option<String> {
186 match self {
187 ChannelEditingState::Create { pending_name, .. } => pending_name.clone(),
188 ChannelEditingState::Rename { pending_name, .. } => pending_name.clone(),
189 }
190 }
191}
192
193pub struct CollabPanel {
194 width: Option<Pixels>,
195 fs: Arc<dyn Fs>,
196 focus_handle: FocusHandle,
197 channel_clipboard: Option<ChannelMoveClipboard>,
198 pending_serialization: Task<Option<()>>,
199 context_menu: Option<(Entity<ContextMenu>, Point<Pixels>, Subscription)>,
200 list_state: ListState,
201 filter_editor: Entity<Editor>,
202 channel_name_editor: Entity<Editor>,
203 channel_editing_state: Option<ChannelEditingState>,
204 entries: Vec<ListEntry>,
205 selection: Option<usize>,
206 channel_store: Entity<ChannelStore>,
207 user_store: Entity<UserStore>,
208 client: Arc<Client>,
209 project: Entity<Project>,
210 match_candidates: Vec<StringMatchCandidate>,
211 subscriptions: Vec<Subscription>,
212 collapsed_sections: Vec<Section>,
213 collapsed_channels: Vec<ChannelId>,
214 workspace: WeakEntity<Workspace>,
215}
216
217#[derive(Serialize, Deserialize)]
218struct SerializedCollabPanel {
219 width: Option<Pixels>,
220 collapsed_channels: Option<Vec<u64>>,
221}
222
223#[derive(Clone, Copy, PartialEq, Eq, Debug, PartialOrd, Ord)]
224enum Section {
225 ActiveCall,
226 Channels,
227 ChannelInvites,
228 ContactRequests,
229 Contacts,
230 Online,
231 Offline,
232}
233
234#[derive(Clone, Debug)]
235enum ListEntry {
236 Header(Section),
237 CallParticipant {
238 user: Arc<User>,
239 peer_id: Option<PeerId>,
240 is_pending: bool,
241 role: proto::ChannelRole,
242 },
243 ParticipantProject {
244 project_id: u64,
245 worktree_root_names: Vec<String>,
246 host_user_id: u64,
247 is_last: bool,
248 },
249 ParticipantScreen {
250 peer_id: Option<PeerId>,
251 is_last: bool,
252 },
253 IncomingRequest(Arc<User>),
254 OutgoingRequest(Arc<User>),
255 ChannelInvite(Arc<Channel>),
256 Channel {
257 channel: Arc<Channel>,
258 depth: usize,
259 has_children: bool,
260 },
261 ChannelNotes {
262 channel_id: ChannelId,
263 },
264 ChannelEditor {
265 depth: usize,
266 },
267 Contact {
268 contact: Arc<Contact>,
269 calling: bool,
270 },
271 ContactPlaceholder,
272}
273
274impl CollabPanel {
275 pub fn new(
276 workspace: &mut Workspace,
277 window: &mut Window,
278 cx: &mut Context<Workspace>,
279 ) -> Entity<Self> {
280 cx.new(|cx| {
281 let filter_editor = cx.new(|cx| {
282 let mut editor = Editor::single_line(window, cx);
283 editor.set_placeholder_text("Filter...", cx);
284 editor
285 });
286
287 cx.subscribe(&filter_editor, |this: &mut Self, _, event, cx| {
288 if let editor::EditorEvent::BufferEdited = event {
289 let query = this.filter_editor.read(cx).text(cx);
290 if !query.is_empty() {
291 this.selection.take();
292 }
293 this.update_entries(true, cx);
294 if !query.is_empty() {
295 this.selection = this
296 .entries
297 .iter()
298 .position(|entry| !matches!(entry, ListEntry::Header(_)));
299 }
300 }
301 })
302 .detach();
303
304 let channel_name_editor = cx.new(|cx| Editor::single_line(window, cx));
305
306 cx.subscribe_in(
307 &channel_name_editor,
308 window,
309 |this: &mut Self, _, event, window, cx| {
310 if let editor::EditorEvent::Blurred = event {
311 if let Some(state) = &this.channel_editing_state
312 && state.pending_name().is_some()
313 {
314 return;
315 }
316 this.take_editing_state(window, cx);
317 this.update_entries(false, cx);
318 cx.notify();
319 }
320 },
321 )
322 .detach();
323
324 let mut this = Self {
325 width: None,
326 focus_handle: cx.focus_handle(),
327 channel_clipboard: None,
328 fs: workspace.app_state().fs.clone(),
329 pending_serialization: Task::ready(None),
330 context_menu: None,
331 list_state: ListState::new(0, gpui::ListAlignment::Top, px(1000.)),
332 channel_name_editor,
333 filter_editor,
334 entries: Vec::default(),
335 channel_editing_state: None,
336 selection: None,
337 channel_store: ChannelStore::global(cx),
338 user_store: workspace.user_store().clone(),
339 project: workspace.project().clone(),
340 subscriptions: Vec::default(),
341 match_candidates: Vec::default(),
342 collapsed_sections: vec![Section::Offline],
343 collapsed_channels: Vec::default(),
344 workspace: workspace.weak_handle(),
345 client: workspace.app_state().client.clone(),
346 };
347
348 this.update_entries(false, cx);
349
350 let active_call = ActiveCall::global(cx);
351 this.subscriptions
352 .push(cx.observe(&this.user_store, |this, _, cx| {
353 this.update_entries(true, cx)
354 }));
355 this.subscriptions
356 .push(cx.observe(&this.channel_store, move |this, _, cx| {
357 this.update_entries(true, cx)
358 }));
359 this.subscriptions
360 .push(cx.observe(&active_call, |this, _, cx| this.update_entries(true, cx)));
361 this.subscriptions.push(cx.subscribe_in(
362 &this.channel_store,
363 window,
364 |this, _channel_store, e, window, cx| match e {
365 ChannelEvent::ChannelCreated(channel_id)
366 | ChannelEvent::ChannelRenamed(channel_id) => {
367 if this.take_editing_state(window, cx) {
368 this.update_entries(false, cx);
369 this.selection = this.entries.iter().position(|entry| {
370 if let ListEntry::Channel { channel, .. } = entry {
371 channel.id == *channel_id
372 } else {
373 false
374 }
375 });
376 }
377 }
378 },
379 ));
380
381 this
382 })
383 }
384
385 pub async fn load(
386 workspace: WeakEntity<Workspace>,
387 mut cx: AsyncWindowContext,
388 ) -> anyhow::Result<Entity<Self>> {
389 let serialized_panel = match workspace
390 .read_with(&cx, |workspace, _| {
391 CollabPanel::serialization_key(workspace)
392 })
393 .ok()
394 .flatten()
395 {
396 Some(serialization_key) => cx
397 .background_spawn(async move { KEY_VALUE_STORE.read_kvp(&serialization_key) })
398 .await
399 .context("reading collaboration panel from key value store")
400 .log_err()
401 .flatten()
402 .map(|panel| serde_json::from_str::<SerializedCollabPanel>(&panel))
403 .transpose()
404 .log_err()
405 .flatten(),
406 None => None,
407 };
408
409 workspace.update_in(&mut cx, |workspace, window, cx| {
410 let panel = CollabPanel::new(workspace, window, cx);
411 if let Some(serialized_panel) = serialized_panel {
412 panel.update(cx, |panel, cx| {
413 panel.width = serialized_panel.width.map(|w| w.round());
414 panel.collapsed_channels = serialized_panel
415 .collapsed_channels
416 .unwrap_or_else(Vec::new)
417 .iter()
418 .map(|cid| ChannelId(*cid))
419 .collect();
420 cx.notify();
421 });
422 }
423 panel
424 })
425 }
426
427 fn serialization_key(workspace: &Workspace) -> Option<String> {
428 workspace
429 .database_id()
430 .map(|id| i64::from(id).to_string())
431 .or(workspace.session_id())
432 .map(|id| format!("{}-{:?}", COLLABORATION_PANEL_KEY, id))
433 }
434
435 fn serialize(&mut self, cx: &mut Context<Self>) {
436 let Some(serialization_key) = self
437 .workspace
438 .read_with(cx, |workspace, _| CollabPanel::serialization_key(workspace))
439 .ok()
440 .flatten()
441 else {
442 return;
443 };
444 let width = self.width;
445 let collapsed_channels = self.collapsed_channels.clone();
446 self.pending_serialization = cx.background_spawn(
447 async move {
448 KEY_VALUE_STORE
449 .write_kvp(
450 serialization_key,
451 serde_json::to_string(&SerializedCollabPanel {
452 width,
453 collapsed_channels: Some(
454 collapsed_channels.iter().map(|cid| cid.0).collect(),
455 ),
456 })?,
457 )
458 .await?;
459 anyhow::Ok(())
460 }
461 .log_err(),
462 );
463 }
464
465 fn scroll_to_item(&mut self, ix: usize) {
466 self.list_state.scroll_to_reveal_item(ix)
467 }
468
469 fn update_entries(&mut self, select_same_item: bool, cx: &mut Context<Self>) {
470 let channel_store = self.channel_store.read(cx);
471 let user_store = self.user_store.read(cx);
472 let query = self.filter_editor.read(cx).text(cx);
473 let executor = cx.background_executor().clone();
474
475 let prev_selected_entry = self.selection.and_then(|ix| self.entries.get(ix).cloned());
476 let old_entries = mem::take(&mut self.entries);
477 let mut scroll_to_top = false;
478
479 if let Some(room) = ActiveCall::global(cx).read(cx).room() {
480 self.entries.push(ListEntry::Header(Section::ActiveCall));
481 if !old_entries
482 .iter()
483 .any(|entry| matches!(entry, ListEntry::Header(Section::ActiveCall)))
484 {
485 scroll_to_top = true;
486 }
487
488 if !self.collapsed_sections.contains(&Section::ActiveCall) {
489 let room = room.read(cx);
490
491 if query.is_empty()
492 && let Some(channel_id) = room.channel_id()
493 {
494 self.entries.push(ListEntry::ChannelNotes { channel_id });
495 }
496
497 // Populate the active user.
498 if let Some(user) = user_store.current_user() {
499 self.match_candidates.clear();
500 self.match_candidates
501 .push(StringMatchCandidate::new(0, &user.github_login));
502 let matches = executor.block(match_strings(
503 &self.match_candidates,
504 &query,
505 true,
506 true,
507 usize::MAX,
508 &Default::default(),
509 executor.clone(),
510 ));
511 if !matches.is_empty() {
512 let user_id = user.id;
513 self.entries.push(ListEntry::CallParticipant {
514 user,
515 peer_id: None,
516 is_pending: false,
517 role: room.local_participant().role,
518 });
519 let mut projects = room.local_participant().projects.iter().peekable();
520 while let Some(project) = projects.next() {
521 self.entries.push(ListEntry::ParticipantProject {
522 project_id: project.id,
523 worktree_root_names: project.worktree_root_names.clone(),
524 host_user_id: user_id,
525 is_last: projects.peek().is_none() && !room.is_sharing_screen(),
526 });
527 }
528 if room.is_sharing_screen() {
529 self.entries.push(ListEntry::ParticipantScreen {
530 peer_id: None,
531 is_last: true,
532 });
533 }
534 }
535 }
536
537 // Populate remote participants.
538 self.match_candidates.clear();
539 self.match_candidates
540 .extend(room.remote_participants().values().map(|participant| {
541 StringMatchCandidate::new(
542 participant.user.id as usize,
543 &participant.user.github_login,
544 )
545 }));
546 let mut matches = executor.block(match_strings(
547 &self.match_candidates,
548 &query,
549 true,
550 true,
551 usize::MAX,
552 &Default::default(),
553 executor.clone(),
554 ));
555 matches.sort_by(|a, b| {
556 let a_is_guest = room.role_for_user(a.candidate_id as u64)
557 == Some(proto::ChannelRole::Guest);
558 let b_is_guest = room.role_for_user(b.candidate_id as u64)
559 == Some(proto::ChannelRole::Guest);
560 a_is_guest
561 .cmp(&b_is_guest)
562 .then_with(|| a.string.cmp(&b.string))
563 });
564 for mat in matches {
565 let user_id = mat.candidate_id as u64;
566 let participant = &room.remote_participants()[&user_id];
567 self.entries.push(ListEntry::CallParticipant {
568 user: participant.user.clone(),
569 peer_id: Some(participant.peer_id),
570 is_pending: false,
571 role: participant.role,
572 });
573 let mut projects = participant.projects.iter().peekable();
574 while let Some(project) = projects.next() {
575 self.entries.push(ListEntry::ParticipantProject {
576 project_id: project.id,
577 worktree_root_names: project.worktree_root_names.clone(),
578 host_user_id: participant.user.id,
579 is_last: projects.peek().is_none() && !participant.has_video_tracks(),
580 });
581 }
582 if participant.has_video_tracks() {
583 self.entries.push(ListEntry::ParticipantScreen {
584 peer_id: Some(participant.peer_id),
585 is_last: true,
586 });
587 }
588 }
589
590 // Populate pending participants.
591 self.match_candidates.clear();
592 self.match_candidates
593 .extend(room.pending_participants().iter().enumerate().map(
594 |(id, participant)| {
595 StringMatchCandidate::new(id, &participant.github_login)
596 },
597 ));
598 let matches = executor.block(match_strings(
599 &self.match_candidates,
600 &query,
601 true,
602 true,
603 usize::MAX,
604 &Default::default(),
605 executor.clone(),
606 ));
607 self.entries
608 .extend(matches.iter().map(|mat| ListEntry::CallParticipant {
609 user: room.pending_participants()[mat.candidate_id].clone(),
610 peer_id: None,
611 is_pending: true,
612 role: proto::ChannelRole::Member,
613 }));
614 }
615 }
616
617 let mut request_entries = Vec::new();
618
619 self.entries.push(ListEntry::Header(Section::Channels));
620
621 if channel_store.channel_count() > 0 || self.channel_editing_state.is_some() {
622 self.match_candidates.clear();
623 self.match_candidates.extend(
624 channel_store
625 .ordered_channels()
626 .enumerate()
627 .map(|(ix, (_, channel))| StringMatchCandidate::new(ix, &channel.name)),
628 );
629 let matches = executor.block(match_strings(
630 &self.match_candidates,
631 &query,
632 true,
633 true,
634 usize::MAX,
635 &Default::default(),
636 executor.clone(),
637 ));
638 if let Some(state) = &self.channel_editing_state
639 && matches!(state, ChannelEditingState::Create { location: None, .. })
640 {
641 self.entries.push(ListEntry::ChannelEditor { depth: 0 });
642 }
643 let mut collapse_depth = None;
644 for mat in matches {
645 let channel = channel_store.channel_at_index(mat.candidate_id).unwrap();
646 let depth = channel.parent_path.len();
647
648 if collapse_depth.is_none() && self.is_channel_collapsed(channel.id) {
649 collapse_depth = Some(depth);
650 } else if let Some(collapsed_depth) = collapse_depth {
651 if depth > collapsed_depth {
652 continue;
653 }
654 if self.is_channel_collapsed(channel.id) {
655 collapse_depth = Some(depth);
656 } else {
657 collapse_depth = None;
658 }
659 }
660
661 let has_children = channel_store
662 .channel_at_index(mat.candidate_id + 1)
663 .is_some_and(|next_channel| next_channel.parent_path.ends_with(&[channel.id]));
664
665 match &self.channel_editing_state {
666 Some(ChannelEditingState::Create {
667 location: parent_id,
668 ..
669 }) if *parent_id == Some(channel.id) => {
670 self.entries.push(ListEntry::Channel {
671 channel: channel.clone(),
672 depth,
673 has_children: false,
674 });
675 self.entries
676 .push(ListEntry::ChannelEditor { depth: depth + 1 });
677 }
678 Some(ChannelEditingState::Rename {
679 location: parent_id,
680 ..
681 }) if parent_id == &channel.id => {
682 self.entries.push(ListEntry::ChannelEditor { depth });
683 }
684 _ => {
685 self.entries.push(ListEntry::Channel {
686 channel: channel.clone(),
687 depth,
688 has_children,
689 });
690 }
691 }
692 }
693 }
694
695 let channel_invites = channel_store.channel_invitations();
696 if !channel_invites.is_empty() {
697 self.match_candidates.clear();
698 self.match_candidates.extend(
699 channel_invites
700 .iter()
701 .enumerate()
702 .map(|(ix, channel)| StringMatchCandidate::new(ix, &channel.name)),
703 );
704 let matches = executor.block(match_strings(
705 &self.match_candidates,
706 &query,
707 true,
708 true,
709 usize::MAX,
710 &Default::default(),
711 executor.clone(),
712 ));
713 request_entries.extend(
714 matches
715 .iter()
716 .map(|mat| ListEntry::ChannelInvite(channel_invites[mat.candidate_id].clone())),
717 );
718
719 if !request_entries.is_empty() {
720 self.entries
721 .push(ListEntry::Header(Section::ChannelInvites));
722 if !self.collapsed_sections.contains(&Section::ChannelInvites) {
723 self.entries.append(&mut request_entries);
724 }
725 }
726 }
727
728 self.entries.push(ListEntry::Header(Section::Contacts));
729
730 request_entries.clear();
731 let incoming = user_store.incoming_contact_requests();
732 if !incoming.is_empty() {
733 self.match_candidates.clear();
734 self.match_candidates.extend(
735 incoming
736 .iter()
737 .enumerate()
738 .map(|(ix, user)| StringMatchCandidate::new(ix, &user.github_login)),
739 );
740 let matches = executor.block(match_strings(
741 &self.match_candidates,
742 &query,
743 true,
744 true,
745 usize::MAX,
746 &Default::default(),
747 executor.clone(),
748 ));
749 request_entries.extend(
750 matches
751 .iter()
752 .map(|mat| ListEntry::IncomingRequest(incoming[mat.candidate_id].clone())),
753 );
754 }
755
756 let outgoing = user_store.outgoing_contact_requests();
757 if !outgoing.is_empty() {
758 self.match_candidates.clear();
759 self.match_candidates.extend(
760 outgoing
761 .iter()
762 .enumerate()
763 .map(|(ix, user)| StringMatchCandidate::new(ix, &user.github_login)),
764 );
765 let matches = executor.block(match_strings(
766 &self.match_candidates,
767 &query,
768 true,
769 true,
770 usize::MAX,
771 &Default::default(),
772 executor.clone(),
773 ));
774 request_entries.extend(
775 matches
776 .iter()
777 .map(|mat| ListEntry::OutgoingRequest(outgoing[mat.candidate_id].clone())),
778 );
779 }
780
781 if !request_entries.is_empty() {
782 self.entries
783 .push(ListEntry::Header(Section::ContactRequests));
784 if !self.collapsed_sections.contains(&Section::ContactRequests) {
785 self.entries.append(&mut request_entries);
786 }
787 }
788
789 let contacts = user_store.contacts();
790 if !contacts.is_empty() {
791 self.match_candidates.clear();
792 self.match_candidates.extend(
793 contacts
794 .iter()
795 .enumerate()
796 .map(|(ix, contact)| StringMatchCandidate::new(ix, &contact.user.github_login)),
797 );
798
799 let matches = executor.block(match_strings(
800 &self.match_candidates,
801 &query,
802 true,
803 true,
804 usize::MAX,
805 &Default::default(),
806 executor.clone(),
807 ));
808
809 let (online_contacts, offline_contacts) = matches
810 .iter()
811 .partition::<Vec<_>, _>(|mat| contacts[mat.candidate_id].online);
812
813 for (matches, section) in [
814 (online_contacts, Section::Online),
815 (offline_contacts, Section::Offline),
816 ] {
817 if !matches.is_empty() {
818 self.entries.push(ListEntry::Header(section));
819 if !self.collapsed_sections.contains(§ion) {
820 let active_call = &ActiveCall::global(cx).read(cx);
821 for mat in matches {
822 let contact = &contacts[mat.candidate_id];
823 self.entries.push(ListEntry::Contact {
824 contact: contact.clone(),
825 calling: active_call.pending_invites().contains(&contact.user.id),
826 });
827 }
828 }
829 }
830 }
831 }
832
833 if incoming.is_empty() && outgoing.is_empty() && contacts.is_empty() {
834 self.entries.push(ListEntry::ContactPlaceholder);
835 }
836
837 if select_same_item {
838 if let Some(prev_selected_entry) = prev_selected_entry {
839 self.selection.take();
840 for (ix, entry) in self.entries.iter().enumerate() {
841 if *entry == prev_selected_entry {
842 self.selection = Some(ix);
843 break;
844 }
845 }
846 }
847 } else {
848 self.selection = self.selection.and_then(|prev_selection| {
849 if self.entries.is_empty() {
850 None
851 } else {
852 Some(prev_selection.min(self.entries.len() - 1))
853 }
854 });
855 }
856
857 let old_scroll_top = self.list_state.logical_scroll_top();
858 self.list_state.reset(self.entries.len());
859
860 if scroll_to_top {
861 self.list_state.scroll_to(ListOffset::default());
862 } else {
863 // Attempt to maintain the same scroll position.
864 if let Some(old_top_entry) = old_entries.get(old_scroll_top.item_ix) {
865 let new_scroll_top = self
866 .entries
867 .iter()
868 .position(|entry| entry == old_top_entry)
869 .map(|item_ix| ListOffset {
870 item_ix,
871 offset_in_item: old_scroll_top.offset_in_item,
872 })
873 .or_else(|| {
874 let entry_after_old_top = old_entries.get(old_scroll_top.item_ix + 1)?;
875 let item_ix = self
876 .entries
877 .iter()
878 .position(|entry| entry == entry_after_old_top)?;
879 Some(ListOffset {
880 item_ix,
881 offset_in_item: Pixels::ZERO,
882 })
883 })
884 .or_else(|| {
885 let entry_before_old_top =
886 old_entries.get(old_scroll_top.item_ix.saturating_sub(1))?;
887 let item_ix = self
888 .entries
889 .iter()
890 .position(|entry| entry == entry_before_old_top)?;
891 Some(ListOffset {
892 item_ix,
893 offset_in_item: Pixels::ZERO,
894 })
895 });
896
897 self.list_state
898 .scroll_to(new_scroll_top.unwrap_or(old_scroll_top));
899 }
900 }
901
902 cx.notify();
903 }
904
905 fn render_call_participant(
906 &self,
907 user: &Arc<User>,
908 peer_id: Option<PeerId>,
909 is_pending: bool,
910 role: proto::ChannelRole,
911 is_selected: bool,
912 cx: &mut Context<Self>,
913 ) -> ListItem {
914 let user_id = user.id;
915 let is_current_user =
916 self.user_store.read(cx).current_user().map(|user| user.id) == Some(user_id);
917 let tooltip = format!("Follow {}", user.github_login);
918
919 let is_call_admin = ActiveCall::global(cx).read(cx).room().is_some_and(|room| {
920 room.read(cx).local_participant().role == proto::ChannelRole::Admin
921 });
922
923 ListItem::new(user.github_login.clone())
924 .start_slot(Avatar::new(user.avatar_uri.clone()))
925 .child(Label::new(user.github_login.clone()))
926 .toggle_state(is_selected)
927 .end_slot(if is_pending {
928 Label::new("Calling").color(Color::Muted).into_any_element()
929 } else if is_current_user {
930 IconButton::new("leave-call", IconName::Exit)
931 .style(ButtonStyle::Subtle)
932 .on_click(move |_, window, cx| Self::leave_call(window, cx))
933 .tooltip(Tooltip::text("Leave Call"))
934 .into_any_element()
935 } else if role == proto::ChannelRole::Guest {
936 Label::new("Guest").color(Color::Muted).into_any_element()
937 } else if role == proto::ChannelRole::Talker {
938 Label::new("Mic only")
939 .color(Color::Muted)
940 .into_any_element()
941 } else {
942 div().into_any_element()
943 })
944 .when_some(peer_id, |el, peer_id| {
945 if role == proto::ChannelRole::Guest {
946 return el;
947 }
948 el.tooltip(Tooltip::text(tooltip.clone()))
949 .on_click(cx.listener(move |this, _, window, cx| {
950 this.workspace
951 .update(cx, |workspace, cx| workspace.follow(peer_id, window, cx))
952 .ok();
953 }))
954 })
955 .when(is_call_admin, |el| {
956 el.on_secondary_mouse_down(cx.listener(
957 move |this, event: &MouseDownEvent, window, cx| {
958 this.deploy_participant_context_menu(
959 event.position,
960 user_id,
961 role,
962 window,
963 cx,
964 )
965 },
966 ))
967 })
968 }
969
970 fn render_participant_project(
971 &self,
972 project_id: u64,
973 worktree_root_names: &[String],
974 host_user_id: u64,
975 is_last: bool,
976 is_selected: bool,
977 window: &mut Window,
978 cx: &mut Context<Self>,
979 ) -> impl IntoElement {
980 let project_name: SharedString = if worktree_root_names.is_empty() {
981 "untitled".to_string()
982 } else {
983 worktree_root_names.join(", ")
984 }
985 .into();
986
987 ListItem::new(project_id as usize)
988 .toggle_state(is_selected)
989 .on_click(cx.listener(move |this, _, window, cx| {
990 this.workspace
991 .update(cx, |workspace, cx| {
992 let app_state = workspace.app_state().clone();
993 workspace::join_in_room_project(project_id, host_user_id, app_state, cx)
994 .detach_and_prompt_err(
995 "Failed to join project",
996 window,
997 cx,
998 |_, _, _| None,
999 );
1000 })
1001 .ok();
1002 }))
1003 .start_slot(
1004 h_flex()
1005 .gap_1()
1006 .child(render_tree_branch(is_last, false, window, cx))
1007 .child(IconButton::new(0, IconName::Folder)),
1008 )
1009 .child(Label::new(project_name.clone()))
1010 .tooltip(Tooltip::text(format!("Open {}", project_name)))
1011 }
1012
1013 fn render_participant_screen(
1014 &self,
1015 peer_id: Option<PeerId>,
1016 is_last: bool,
1017 is_selected: bool,
1018 window: &mut Window,
1019 cx: &mut Context<Self>,
1020 ) -> impl IntoElement {
1021 let id = peer_id.map_or(usize::MAX, |id| id.as_u64() as usize);
1022
1023 ListItem::new(("screen", id))
1024 .toggle_state(is_selected)
1025 .start_slot(
1026 h_flex()
1027 .gap_1()
1028 .child(render_tree_branch(is_last, false, window, cx))
1029 .child(IconButton::new(0, IconName::Screen)),
1030 )
1031 .child(Label::new("Screen"))
1032 .when_some(peer_id, |this, _| {
1033 this.on_click(cx.listener(move |this, _, window, cx| {
1034 this.workspace
1035 .update(cx, |workspace, cx| {
1036 workspace.open_shared_screen(peer_id.unwrap(), window, cx)
1037 })
1038 .ok();
1039 }))
1040 .tooltip(Tooltip::text("Open shared screen"))
1041 })
1042 }
1043
1044 fn take_editing_state(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
1045 if self.channel_editing_state.take().is_some() {
1046 self.channel_name_editor.update(cx, |editor, cx| {
1047 editor.set_text("", window, cx);
1048 });
1049 true
1050 } else {
1051 false
1052 }
1053 }
1054
1055 fn render_channel_notes(
1056 &self,
1057 channel_id: ChannelId,
1058 is_selected: bool,
1059 window: &mut Window,
1060 cx: &mut Context<Self>,
1061 ) -> impl IntoElement {
1062 let channel_store = self.channel_store.read(cx);
1063 let has_channel_buffer_changed = channel_store.has_channel_buffer_changed(channel_id);
1064 ListItem::new("channel-notes")
1065 .toggle_state(is_selected)
1066 .on_click(cx.listener(move |this, _, window, cx| {
1067 this.open_channel_notes(channel_id, window, cx);
1068 }))
1069 .start_slot(
1070 h_flex()
1071 .relative()
1072 .gap_1()
1073 .child(render_tree_branch(false, true, window, cx))
1074 .child(IconButton::new(0, IconName::File))
1075 .children(has_channel_buffer_changed.then(|| {
1076 div()
1077 .w_1p5()
1078 .absolute()
1079 .right(px(2.))
1080 .top(px(2.))
1081 .child(Indicator::dot().color(Color::Info))
1082 })),
1083 )
1084 .child(Label::new("notes"))
1085 .tooltip(Tooltip::text("Open Channel Notes"))
1086 }
1087
1088 fn has_subchannels(&self, ix: usize) -> bool {
1089 self.entries.get(ix).is_some_and(|entry| {
1090 if let ListEntry::Channel { has_children, .. } = entry {
1091 *has_children
1092 } else {
1093 false
1094 }
1095 })
1096 }
1097
1098 fn deploy_participant_context_menu(
1099 &mut self,
1100 position: Point<Pixels>,
1101 user_id: u64,
1102 role: proto::ChannelRole,
1103 window: &mut Window,
1104 cx: &mut Context<Self>,
1105 ) {
1106 let this = cx.entity();
1107 if !(role == proto::ChannelRole::Guest
1108 || role == proto::ChannelRole::Talker
1109 || role == proto::ChannelRole::Member)
1110 {
1111 return;
1112 }
1113
1114 let context_menu = ContextMenu::build(window, cx, |mut context_menu, window, _| {
1115 if role == proto::ChannelRole::Guest {
1116 context_menu = context_menu.entry(
1117 "Grant Mic Access",
1118 None,
1119 window.handler_for(&this, move |_, window, cx| {
1120 ActiveCall::global(cx)
1121 .update(cx, |call, cx| {
1122 let Some(room) = call.room() else {
1123 return Task::ready(Ok(()));
1124 };
1125 room.update(cx, |room, cx| {
1126 room.set_participant_role(
1127 user_id,
1128 proto::ChannelRole::Talker,
1129 cx,
1130 )
1131 })
1132 })
1133 .detach_and_prompt_err(
1134 "Failed to grant mic access",
1135 window,
1136 cx,
1137 |_, _, _| None,
1138 )
1139 }),
1140 );
1141 }
1142 if role == proto::ChannelRole::Guest || role == proto::ChannelRole::Talker {
1143 context_menu = context_menu.entry(
1144 "Grant Write Access",
1145 None,
1146 window.handler_for(&this, move |_, window, cx| {
1147 ActiveCall::global(cx)
1148 .update(cx, |call, cx| {
1149 let Some(room) = call.room() else {
1150 return Task::ready(Ok(()));
1151 };
1152 room.update(cx, |room, cx| {
1153 room.set_participant_role(
1154 user_id,
1155 proto::ChannelRole::Member,
1156 cx,
1157 )
1158 })
1159 })
1160 .detach_and_prompt_err("Failed to grant write access", window, cx, |e, _, _| {
1161 match e.error_code() {
1162 ErrorCode::NeedsCla => Some("This user has not yet signed the CLA at https://zed.dev/cla.".into()),
1163 _ => None,
1164 }
1165 })
1166 }),
1167 );
1168 }
1169 if role == proto::ChannelRole::Member || role == proto::ChannelRole::Talker {
1170 let label = if role == proto::ChannelRole::Talker {
1171 "Mute"
1172 } else {
1173 "Revoke Access"
1174 };
1175 context_menu = context_menu.entry(
1176 label,
1177 None,
1178 window.handler_for(&this, move |_, window, cx| {
1179 ActiveCall::global(cx)
1180 .update(cx, |call, cx| {
1181 let Some(room) = call.room() else {
1182 return Task::ready(Ok(()));
1183 };
1184 room.update(cx, |room, cx| {
1185 room.set_participant_role(
1186 user_id,
1187 proto::ChannelRole::Guest,
1188 cx,
1189 )
1190 })
1191 })
1192 .detach_and_prompt_err(
1193 "Failed to revoke access",
1194 window,
1195 cx,
1196 |_, _, _| None,
1197 )
1198 }),
1199 );
1200 }
1201
1202 context_menu
1203 });
1204
1205 window.focus(&context_menu.focus_handle(cx));
1206 let subscription = cx.subscribe_in(
1207 &context_menu,
1208 window,
1209 |this, _, _: &DismissEvent, window, cx| {
1210 if this.context_menu.as_ref().is_some_and(|context_menu| {
1211 context_menu.0.focus_handle(cx).contains_focused(window, cx)
1212 }) {
1213 cx.focus_self(window);
1214 }
1215 this.context_menu.take();
1216 cx.notify();
1217 },
1218 );
1219 self.context_menu = Some((context_menu, position, subscription));
1220 }
1221
1222 fn deploy_channel_context_menu(
1223 &mut self,
1224 position: Point<Pixels>,
1225 channel_id: ChannelId,
1226 ix: usize,
1227 window: &mut Window,
1228 cx: &mut Context<Self>,
1229 ) {
1230 let clipboard_channel_name = self.channel_clipboard.as_ref().and_then(|clipboard| {
1231 self.channel_store
1232 .read(cx)
1233 .channel_for_id(clipboard.channel_id)
1234 .map(|channel| channel.name.clone())
1235 });
1236 let this = cx.entity();
1237
1238 let context_menu = ContextMenu::build(window, cx, |mut context_menu, window, cx| {
1239 if self.has_subchannels(ix) {
1240 let expand_action_name = if self.is_channel_collapsed(channel_id) {
1241 "Expand Subchannels"
1242 } else {
1243 "Collapse Subchannels"
1244 };
1245 context_menu = context_menu.entry(
1246 expand_action_name,
1247 None,
1248 window.handler_for(&this, move |this, window, cx| {
1249 this.toggle_channel_collapsed(channel_id, window, cx)
1250 }),
1251 );
1252 }
1253
1254 context_menu = context_menu
1255 .entry(
1256 "Open Notes",
1257 None,
1258 window.handler_for(&this, move |this, window, cx| {
1259 this.open_channel_notes(channel_id, window, cx)
1260 }),
1261 )
1262 .entry(
1263 "Copy Channel Link",
1264 None,
1265 window.handler_for(&this, move |this, _, cx| {
1266 this.copy_channel_link(channel_id, cx)
1267 }),
1268 );
1269
1270 let mut has_destructive_actions = false;
1271 if self.channel_store.read(cx).is_channel_admin(channel_id) {
1272 has_destructive_actions = true;
1273 context_menu = context_menu
1274 .separator()
1275 .entry(
1276 "New Subchannel",
1277 None,
1278 window.handler_for(&this, move |this, window, cx| {
1279 this.new_subchannel(channel_id, window, cx)
1280 }),
1281 )
1282 .entry(
1283 "Rename",
1284 Some(Box::new(SecondaryConfirm)),
1285 window.handler_for(&this, move |this, window, cx| {
1286 this.rename_channel(channel_id, window, cx)
1287 }),
1288 );
1289
1290 if let Some(channel_name) = clipboard_channel_name {
1291 context_menu = context_menu.separator().entry(
1292 format!("Move '#{}' here", channel_name),
1293 None,
1294 window.handler_for(&this, move |this, window, cx| {
1295 this.move_channel_on_clipboard(channel_id, window, cx)
1296 }),
1297 );
1298 }
1299
1300 if self.channel_store.read(cx).is_root_channel(channel_id) {
1301 context_menu = context_menu.separator().entry(
1302 "Manage Members",
1303 None,
1304 window.handler_for(&this, move |this, window, cx| {
1305 this.manage_members(channel_id, window, cx)
1306 }),
1307 )
1308 } else {
1309 context_menu = context_menu.entry(
1310 "Move this channel",
1311 None,
1312 window.handler_for(&this, move |this, window, cx| {
1313 this.start_move_channel(channel_id, window, cx)
1314 }),
1315 );
1316 if self.channel_store.read(cx).is_public_channel(channel_id) {
1317 context_menu = context_menu.separator().entry(
1318 "Make Channel Private",
1319 None,
1320 window.handler_for(&this, move |this, window, cx| {
1321 this.set_channel_visibility(
1322 channel_id,
1323 ChannelVisibility::Members,
1324 window,
1325 cx,
1326 )
1327 }),
1328 )
1329 } else {
1330 context_menu = context_menu.separator().entry(
1331 "Make Channel Public",
1332 None,
1333 window.handler_for(&this, move |this, window, cx| {
1334 this.set_channel_visibility(
1335 channel_id,
1336 ChannelVisibility::Public,
1337 window,
1338 cx,
1339 )
1340 }),
1341 )
1342 }
1343 }
1344
1345 context_menu = context_menu.entry(
1346 "Delete",
1347 None,
1348 window.handler_for(&this, move |this, window, cx| {
1349 this.remove_channel(channel_id, window, cx)
1350 }),
1351 );
1352 }
1353
1354 if self.channel_store.read(cx).is_root_channel(channel_id) {
1355 if !has_destructive_actions {
1356 context_menu = context_menu.separator()
1357 }
1358 context_menu = context_menu.entry(
1359 "Leave Channel",
1360 None,
1361 window.handler_for(&this, move |this, window, cx| {
1362 this.leave_channel(channel_id, window, cx)
1363 }),
1364 );
1365 }
1366
1367 context_menu
1368 });
1369
1370 window.focus(&context_menu.focus_handle(cx));
1371 let subscription = cx.subscribe_in(
1372 &context_menu,
1373 window,
1374 |this, _, _: &DismissEvent, window, cx| {
1375 if this.context_menu.as_ref().is_some_and(|context_menu| {
1376 context_menu.0.focus_handle(cx).contains_focused(window, cx)
1377 }) {
1378 cx.focus_self(window);
1379 }
1380 this.context_menu.take();
1381 cx.notify();
1382 },
1383 );
1384 self.context_menu = Some((context_menu, position, subscription));
1385
1386 cx.notify();
1387 }
1388
1389 fn deploy_contact_context_menu(
1390 &mut self,
1391 position: Point<Pixels>,
1392 contact: Arc<Contact>,
1393 window: &mut Window,
1394 cx: &mut Context<Self>,
1395 ) {
1396 let this = cx.entity();
1397 let in_room = ActiveCall::global(cx).read(cx).room().is_some();
1398
1399 let context_menu = ContextMenu::build(window, cx, |mut context_menu, _, _| {
1400 let user_id = contact.user.id;
1401
1402 if contact.online && !contact.busy {
1403 let label = if in_room {
1404 format!("Invite {} to join", contact.user.github_login)
1405 } else {
1406 format!("Call {}", contact.user.github_login)
1407 };
1408 context_menu = context_menu.entry(label, None, {
1409 let this = this.clone();
1410 move |window, cx| {
1411 this.update(cx, |this, cx| {
1412 this.call(user_id, window, cx);
1413 });
1414 }
1415 });
1416 }
1417
1418 context_menu.entry("Remove Contact", None, {
1419 let this = this.clone();
1420 move |window, cx| {
1421 this.update(cx, |this, cx| {
1422 this.remove_contact(
1423 contact.user.id,
1424 &contact.user.github_login,
1425 window,
1426 cx,
1427 );
1428 });
1429 }
1430 })
1431 });
1432
1433 window.focus(&context_menu.focus_handle(cx));
1434 let subscription = cx.subscribe_in(
1435 &context_menu,
1436 window,
1437 |this, _, _: &DismissEvent, window, cx| {
1438 if this.context_menu.as_ref().is_some_and(|context_menu| {
1439 context_menu.0.focus_handle(cx).contains_focused(window, cx)
1440 }) {
1441 cx.focus_self(window);
1442 }
1443 this.context_menu.take();
1444 cx.notify();
1445 },
1446 );
1447 self.context_menu = Some((context_menu, position, subscription));
1448
1449 cx.notify();
1450 }
1451
1452 fn reset_filter_editor_text(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
1453 self.filter_editor.update(cx, |editor, cx| {
1454 if editor.buffer().read(cx).len(cx) > 0 {
1455 editor.set_text("", window, cx);
1456 true
1457 } else {
1458 false
1459 }
1460 })
1461 }
1462
1463 fn cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context<Self>) {
1464 if cx.stop_active_drag(window) {
1465 return;
1466 } else if self.take_editing_state(window, cx) {
1467 window.focus(&self.filter_editor.focus_handle(cx));
1468 } else if !self.reset_filter_editor_text(window, cx) {
1469 self.focus_handle.focus(window);
1470 }
1471
1472 if self.context_menu.is_some() {
1473 self.context_menu.take();
1474 cx.notify();
1475 }
1476
1477 self.update_entries(false, cx);
1478 }
1479
1480 fn select_next(&mut self, _: &SelectNext, _: &mut Window, cx: &mut Context<Self>) {
1481 let ix = self.selection.map_or(0, |ix| ix + 1);
1482 if ix < self.entries.len() {
1483 self.selection = Some(ix);
1484 }
1485
1486 if let Some(ix) = self.selection {
1487 self.scroll_to_item(ix)
1488 }
1489 cx.notify();
1490 }
1491
1492 fn select_previous(&mut self, _: &SelectPrevious, _: &mut Window, cx: &mut Context<Self>) {
1493 let ix = self.selection.take().unwrap_or(0);
1494 if ix > 0 {
1495 self.selection = Some(ix - 1);
1496 }
1497
1498 if let Some(ix) = self.selection {
1499 self.scroll_to_item(ix)
1500 }
1501 cx.notify();
1502 }
1503
1504 fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
1505 if self.confirm_channel_edit(window, cx) {
1506 return;
1507 }
1508
1509 if let Some(selection) = self.selection
1510 && let Some(entry) = self.entries.get(selection)
1511 {
1512 match entry {
1513 ListEntry::Header(section) => match section {
1514 Section::ActiveCall => Self::leave_call(window, cx),
1515 Section::Channels => self.new_root_channel(window, cx),
1516 Section::Contacts => self.toggle_contact_finder(window, cx),
1517 Section::ContactRequests
1518 | Section::Online
1519 | Section::Offline
1520 | Section::ChannelInvites => {
1521 self.toggle_section_expanded(*section, cx);
1522 }
1523 },
1524 ListEntry::Contact { contact, calling } => {
1525 if contact.online && !contact.busy && !calling {
1526 self.call(contact.user.id, window, cx);
1527 }
1528 }
1529 ListEntry::ParticipantProject {
1530 project_id,
1531 host_user_id,
1532 ..
1533 } => {
1534 if let Some(workspace) = self.workspace.upgrade() {
1535 let app_state = workspace.read(cx).app_state().clone();
1536 workspace::join_in_room_project(*project_id, *host_user_id, app_state, cx)
1537 .detach_and_prompt_err(
1538 "Failed to join project",
1539 window,
1540 cx,
1541 |_, _, _| None,
1542 );
1543 }
1544 }
1545 ListEntry::ParticipantScreen { peer_id, .. } => {
1546 let Some(peer_id) = peer_id else {
1547 return;
1548 };
1549 if let Some(workspace) = self.workspace.upgrade() {
1550 workspace.update(cx, |workspace, cx| {
1551 workspace.open_shared_screen(*peer_id, window, cx)
1552 });
1553 }
1554 }
1555 ListEntry::Channel { channel, .. } => {
1556 let is_active = maybe!({
1557 let call_channel = ActiveCall::global(cx)
1558 .read(cx)
1559 .room()?
1560 .read(cx)
1561 .channel_id()?;
1562
1563 Some(call_channel == channel.id)
1564 })
1565 .unwrap_or(false);
1566 if is_active {
1567 self.open_channel_notes(channel.id, window, cx)
1568 } else {
1569 self.join_channel(channel.id, window, cx)
1570 }
1571 }
1572 ListEntry::ContactPlaceholder => self.toggle_contact_finder(window, cx),
1573 ListEntry::CallParticipant { user, peer_id, .. } => {
1574 if Some(user) == self.user_store.read(cx).current_user().as_ref() {
1575 Self::leave_call(window, cx);
1576 } else if let Some(peer_id) = peer_id {
1577 self.workspace
1578 .update(cx, |workspace, cx| workspace.follow(*peer_id, window, cx))
1579 .ok();
1580 }
1581 }
1582 ListEntry::IncomingRequest(user) => {
1583 self.respond_to_contact_request(user.id, true, window, cx)
1584 }
1585 ListEntry::ChannelInvite(channel) => {
1586 self.respond_to_channel_invite(channel.id, true, cx)
1587 }
1588 ListEntry::ChannelNotes { channel_id } => {
1589 self.open_channel_notes(*channel_id, window, cx)
1590 }
1591 ListEntry::OutgoingRequest(_) => {}
1592 ListEntry::ChannelEditor { .. } => {}
1593 }
1594 }
1595 }
1596
1597 fn insert_space(&mut self, _: &InsertSpace, window: &mut Window, cx: &mut Context<Self>) {
1598 if self.channel_editing_state.is_some() {
1599 self.channel_name_editor.update(cx, |editor, cx| {
1600 editor.insert(" ", window, cx);
1601 });
1602 } else if self.filter_editor.focus_handle(cx).is_focused(window) {
1603 self.filter_editor.update(cx, |editor, cx| {
1604 editor.insert(" ", window, cx);
1605 });
1606 }
1607 }
1608
1609 fn confirm_channel_edit(&mut self, window: &mut Window, cx: &mut Context<CollabPanel>) -> bool {
1610 if let Some(editing_state) = &mut self.channel_editing_state {
1611 match editing_state {
1612 ChannelEditingState::Create {
1613 location,
1614 pending_name,
1615 ..
1616 } => {
1617 if pending_name.is_some() {
1618 return false;
1619 }
1620 let channel_name = self.channel_name_editor.read(cx).text(cx);
1621
1622 *pending_name = Some(channel_name.clone());
1623
1624 let create = self.channel_store.update(cx, |channel_store, cx| {
1625 channel_store.create_channel(&channel_name, *location, cx)
1626 });
1627 if location.is_none() {
1628 cx.spawn_in(window, async move |this, cx| {
1629 let channel_id = create.await?;
1630 this.update_in(cx, |this, window, cx| {
1631 this.show_channel_modal(
1632 channel_id,
1633 channel_modal::Mode::InviteMembers,
1634 window,
1635 cx,
1636 )
1637 })
1638 })
1639 .detach_and_prompt_err(
1640 "Failed to create channel",
1641 window,
1642 cx,
1643 |_, _, _| None,
1644 );
1645 } else {
1646 create.detach_and_prompt_err(
1647 "Failed to create channel",
1648 window,
1649 cx,
1650 |_, _, _| None,
1651 );
1652 }
1653 cx.notify();
1654 }
1655 ChannelEditingState::Rename {
1656 location,
1657 pending_name,
1658 } => {
1659 if pending_name.is_some() {
1660 return false;
1661 }
1662 let channel_name = self.channel_name_editor.read(cx).text(cx);
1663 *pending_name = Some(channel_name.clone());
1664
1665 self.channel_store
1666 .update(cx, |channel_store, cx| {
1667 channel_store.rename(*location, &channel_name, cx)
1668 })
1669 .detach();
1670 cx.notify();
1671 }
1672 }
1673 cx.focus_self(window);
1674 true
1675 } else {
1676 false
1677 }
1678 }
1679
1680 fn toggle_section_expanded(&mut self, section: Section, cx: &mut Context<Self>) {
1681 if let Some(ix) = self.collapsed_sections.iter().position(|s| *s == section) {
1682 self.collapsed_sections.remove(ix);
1683 } else {
1684 self.collapsed_sections.push(section);
1685 }
1686 self.update_entries(false, cx);
1687 }
1688
1689 fn collapse_selected_channel(
1690 &mut self,
1691 _: &CollapseSelectedChannel,
1692 window: &mut Window,
1693 cx: &mut Context<Self>,
1694 ) {
1695 let Some(channel_id) = self.selected_channel().map(|channel| channel.id) else {
1696 return;
1697 };
1698
1699 if self.is_channel_collapsed(channel_id) {
1700 return;
1701 }
1702
1703 self.toggle_channel_collapsed(channel_id, window, cx);
1704 }
1705
1706 fn expand_selected_channel(
1707 &mut self,
1708 _: &ExpandSelectedChannel,
1709 window: &mut Window,
1710 cx: &mut Context<Self>,
1711 ) {
1712 let Some(id) = self.selected_channel().map(|channel| channel.id) else {
1713 return;
1714 };
1715
1716 if !self.is_channel_collapsed(id) {
1717 return;
1718 }
1719
1720 self.toggle_channel_collapsed(id, window, cx)
1721 }
1722
1723 fn toggle_channel_collapsed(
1724 &mut self,
1725 channel_id: ChannelId,
1726 window: &mut Window,
1727 cx: &mut Context<Self>,
1728 ) {
1729 match self.collapsed_channels.binary_search(&channel_id) {
1730 Ok(ix) => {
1731 self.collapsed_channels.remove(ix);
1732 }
1733 Err(ix) => {
1734 self.collapsed_channels.insert(ix, channel_id);
1735 }
1736 };
1737 self.serialize(cx);
1738 self.update_entries(true, cx);
1739 cx.notify();
1740 cx.focus_self(window);
1741 }
1742
1743 fn is_channel_collapsed(&self, channel_id: ChannelId) -> bool {
1744 self.collapsed_channels.binary_search(&channel_id).is_ok()
1745 }
1746
1747 fn leave_call(window: &mut Window, cx: &mut App) {
1748 ActiveCall::global(cx)
1749 .update(cx, |call, cx| call.hang_up(cx))
1750 .detach_and_prompt_err("Failed to hang up", window, cx, |_, _, _| None);
1751 }
1752
1753 fn toggle_contact_finder(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1754 if let Some(workspace) = self.workspace.upgrade() {
1755 workspace.update(cx, |workspace, cx| {
1756 workspace.toggle_modal(window, cx, |window, cx| {
1757 let mut finder = ContactFinder::new(self.user_store.clone(), window, cx);
1758 finder.set_query(self.filter_editor.read(cx).text(cx), window, cx);
1759 finder
1760 });
1761 });
1762 }
1763 }
1764
1765 fn new_root_channel(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1766 self.channel_editing_state = Some(ChannelEditingState::Create {
1767 location: None,
1768 pending_name: None,
1769 });
1770 self.update_entries(false, cx);
1771 self.select_channel_editor();
1772 window.focus(&self.channel_name_editor.focus_handle(cx));
1773 cx.notify();
1774 }
1775
1776 fn select_channel_editor(&mut self) {
1777 self.selection = self
1778 .entries
1779 .iter()
1780 .position(|entry| matches!(entry, ListEntry::ChannelEditor { .. }));
1781 }
1782
1783 fn new_subchannel(
1784 &mut self,
1785 channel_id: ChannelId,
1786 window: &mut Window,
1787 cx: &mut Context<Self>,
1788 ) {
1789 self.collapsed_channels
1790 .retain(|channel| *channel != channel_id);
1791 self.channel_editing_state = Some(ChannelEditingState::Create {
1792 location: Some(channel_id),
1793 pending_name: None,
1794 });
1795 self.update_entries(false, cx);
1796 self.select_channel_editor();
1797 window.focus(&self.channel_name_editor.focus_handle(cx));
1798 cx.notify();
1799 }
1800
1801 fn manage_members(
1802 &mut self,
1803 channel_id: ChannelId,
1804 window: &mut Window,
1805 cx: &mut Context<Self>,
1806 ) {
1807 self.show_channel_modal(channel_id, channel_modal::Mode::ManageMembers, window, cx);
1808 }
1809
1810 fn remove_selected_channel(&mut self, _: &Remove, window: &mut Window, cx: &mut Context<Self>) {
1811 if let Some(channel) = self.selected_channel() {
1812 self.remove_channel(channel.id, window, cx)
1813 }
1814 }
1815
1816 fn rename_selected_channel(
1817 &mut self,
1818 _: &SecondaryConfirm,
1819 window: &mut Window,
1820 cx: &mut Context<Self>,
1821 ) {
1822 if let Some(channel) = self.selected_channel() {
1823 self.rename_channel(channel.id, window, cx);
1824 }
1825 }
1826
1827 fn rename_channel(
1828 &mut self,
1829 channel_id: ChannelId,
1830 window: &mut Window,
1831 cx: &mut Context<Self>,
1832 ) {
1833 let channel_store = self.channel_store.read(cx);
1834 if !channel_store.is_channel_admin(channel_id) {
1835 return;
1836 }
1837 if let Some(channel) = channel_store.channel_for_id(channel_id).cloned() {
1838 self.channel_editing_state = Some(ChannelEditingState::Rename {
1839 location: channel_id,
1840 pending_name: None,
1841 });
1842 self.channel_name_editor.update(cx, |editor, cx| {
1843 editor.set_text(channel.name.clone(), window, cx);
1844 editor.select_all(&Default::default(), window, cx);
1845 });
1846 window.focus(&self.channel_name_editor.focus_handle(cx));
1847 self.update_entries(false, cx);
1848 self.select_channel_editor();
1849 }
1850 }
1851
1852 fn set_channel_visibility(
1853 &mut self,
1854 channel_id: ChannelId,
1855 visibility: ChannelVisibility,
1856 window: &mut Window,
1857 cx: &mut Context<Self>,
1858 ) {
1859 self.channel_store
1860 .update(cx, |channel_store, cx| {
1861 channel_store.set_channel_visibility(channel_id, visibility, cx)
1862 })
1863 .detach_and_prompt_err("Failed to set channel visibility", window, cx, |e, _, _| match e.error_code() {
1864 ErrorCode::BadPublicNesting =>
1865 if e.error_tag("direction") == Some("parent") {
1866 Some("To make a channel public, its parent channel must be public.".to_string())
1867 } else {
1868 Some("To make a channel private, all of its subchannels must be private.".to_string())
1869 },
1870 _ => None
1871 });
1872 }
1873
1874 fn start_move_channel(
1875 &mut self,
1876 channel_id: ChannelId,
1877 _window: &mut Window,
1878 _cx: &mut Context<Self>,
1879 ) {
1880 self.channel_clipboard = Some(ChannelMoveClipboard { channel_id });
1881 }
1882
1883 fn start_move_selected_channel(
1884 &mut self,
1885 _: &StartMoveChannel,
1886 window: &mut Window,
1887 cx: &mut Context<Self>,
1888 ) {
1889 if let Some(channel) = self.selected_channel() {
1890 self.start_move_channel(channel.id, window, cx);
1891 }
1892 }
1893
1894 fn move_channel_on_clipboard(
1895 &mut self,
1896 to_channel_id: ChannelId,
1897 window: &mut Window,
1898 cx: &mut Context<CollabPanel>,
1899 ) {
1900 if let Some(clipboard) = self.channel_clipboard.take() {
1901 self.move_channel(clipboard.channel_id, to_channel_id, window, cx)
1902 }
1903 }
1904
1905 fn move_channel(
1906 &self,
1907 channel_id: ChannelId,
1908 to: ChannelId,
1909 window: &mut Window,
1910 cx: &mut Context<Self>,
1911 ) {
1912 self.channel_store
1913 .update(cx, |channel_store, cx| {
1914 channel_store.move_channel(channel_id, to, cx)
1915 })
1916 .detach_and_prompt_err("Failed to move channel", window, cx, |e, _, _| {
1917 match e.error_code() {
1918 ErrorCode::BadPublicNesting => {
1919 Some("Public channels must have public parents".into())
1920 }
1921 ErrorCode::CircularNesting => {
1922 Some("You cannot move a channel into itself".into())
1923 }
1924 ErrorCode::WrongMoveTarget => {
1925 Some("You cannot move a channel into a different root channel".into())
1926 }
1927 _ => None,
1928 }
1929 })
1930 }
1931
1932 fn move_channel_up(&mut self, _: &MoveChannelUp, window: &mut Window, cx: &mut Context<Self>) {
1933 if let Some(channel) = self.selected_channel() {
1934 self.channel_store.update(cx, |store, cx| {
1935 store
1936 .reorder_channel(channel.id, proto::reorder_channel::Direction::Up, cx)
1937 .detach_and_prompt_err("Failed to move channel up", window, cx, |_, _, _| None)
1938 });
1939 }
1940 }
1941
1942 fn move_channel_down(
1943 &mut self,
1944 _: &MoveChannelDown,
1945 window: &mut Window,
1946 cx: &mut Context<Self>,
1947 ) {
1948 if let Some(channel) = self.selected_channel() {
1949 self.channel_store.update(cx, |store, cx| {
1950 store
1951 .reorder_channel(channel.id, proto::reorder_channel::Direction::Down, cx)
1952 .detach_and_prompt_err("Failed to move channel down", window, cx, |_, _, _| {
1953 None
1954 })
1955 });
1956 }
1957 }
1958
1959 fn open_channel_notes(
1960 &mut self,
1961 channel_id: ChannelId,
1962 window: &mut Window,
1963 cx: &mut Context<Self>,
1964 ) {
1965 if let Some(workspace) = self.workspace.upgrade() {
1966 ChannelView::open(channel_id, None, workspace, window, cx).detach();
1967 }
1968 }
1969
1970 fn show_inline_context_menu(
1971 &mut self,
1972 _: &Secondary,
1973 window: &mut Window,
1974 cx: &mut Context<Self>,
1975 ) {
1976 let Some(bounds) = self
1977 .selection
1978 .and_then(|ix| self.list_state.bounds_for_item(ix))
1979 else {
1980 return;
1981 };
1982
1983 if let Some(channel) = self.selected_channel() {
1984 self.deploy_channel_context_menu(
1985 bounds.center(),
1986 channel.id,
1987 self.selection.unwrap(),
1988 window,
1989 cx,
1990 );
1991 cx.stop_propagation();
1992 return;
1993 };
1994
1995 if let Some(contact) = self.selected_contact() {
1996 self.deploy_contact_context_menu(bounds.center(), contact, window, cx);
1997 cx.stop_propagation();
1998 }
1999 }
2000
2001 fn dispatch_context(&self, window: &Window, cx: &Context<Self>) -> KeyContext {
2002 let mut dispatch_context = KeyContext::new_with_defaults();
2003 dispatch_context.add("CollabPanel");
2004 dispatch_context.add("menu");
2005
2006 let identifier = if self.channel_name_editor.focus_handle(cx).is_focused(window)
2007 || self.filter_editor.focus_handle(cx).is_focused(window)
2008 {
2009 "editing"
2010 } else {
2011 "not_editing"
2012 };
2013
2014 dispatch_context.add(identifier);
2015 dispatch_context
2016 }
2017
2018 fn selected_channel(&self) -> Option<&Arc<Channel>> {
2019 self.selection
2020 .and_then(|ix| self.entries.get(ix))
2021 .and_then(|entry| match entry {
2022 ListEntry::Channel { channel, .. } => Some(channel),
2023 _ => None,
2024 })
2025 }
2026
2027 fn selected_contact(&self) -> Option<Arc<Contact>> {
2028 self.selection
2029 .and_then(|ix| self.entries.get(ix))
2030 .and_then(|entry| match entry {
2031 ListEntry::Contact { contact, .. } => Some(contact.clone()),
2032 _ => None,
2033 })
2034 }
2035
2036 fn show_channel_modal(
2037 &mut self,
2038 channel_id: ChannelId,
2039 mode: channel_modal::Mode,
2040 window: &mut Window,
2041 cx: &mut Context<Self>,
2042 ) {
2043 let workspace = self.workspace.clone();
2044 let user_store = self.user_store.clone();
2045 let channel_store = self.channel_store.clone();
2046
2047 cx.spawn_in(window, async move |_, cx| {
2048 workspace.update_in(cx, |workspace, window, cx| {
2049 workspace.toggle_modal(window, cx, |window, cx| {
2050 ChannelModal::new(
2051 user_store.clone(),
2052 channel_store.clone(),
2053 channel_id,
2054 mode,
2055 window,
2056 cx,
2057 )
2058 });
2059 })
2060 })
2061 .detach();
2062 }
2063
2064 fn leave_channel(&self, channel_id: ChannelId, window: &mut Window, cx: &mut Context<Self>) {
2065 let Some(user_id) = self.user_store.read(cx).current_user().map(|u| u.id) else {
2066 return;
2067 };
2068 let Some(channel) = self.channel_store.read(cx).channel_for_id(channel_id) else {
2069 return;
2070 };
2071 let prompt_message = format!("Are you sure you want to leave \"#{}\"?", channel.name);
2072 let answer = window.prompt(
2073 PromptLevel::Warning,
2074 &prompt_message,
2075 None,
2076 &["Leave", "Cancel"],
2077 cx,
2078 );
2079 cx.spawn_in(window, async move |this, cx| {
2080 if answer.await? != 0 {
2081 return Ok(());
2082 }
2083 this.update(cx, |this, cx| {
2084 this.channel_store.update(cx, |channel_store, cx| {
2085 channel_store.remove_member(channel_id, user_id, cx)
2086 })
2087 })?
2088 .await
2089 })
2090 .detach_and_prompt_err("Failed to leave channel", window, cx, |_, _, _| None)
2091 }
2092
2093 fn remove_channel(
2094 &mut self,
2095 channel_id: ChannelId,
2096 window: &mut Window,
2097 cx: &mut Context<Self>,
2098 ) {
2099 let channel_store = self.channel_store.clone();
2100 if let Some(channel) = channel_store.read(cx).channel_for_id(channel_id) {
2101 let prompt_message = format!(
2102 "Are you sure you want to remove the channel \"{}\"?",
2103 channel.name
2104 );
2105 let answer = window.prompt(
2106 PromptLevel::Warning,
2107 &prompt_message,
2108 None,
2109 &["Remove", "Cancel"],
2110 cx,
2111 );
2112 cx.spawn_in(window, async move |this, cx| {
2113 if answer.await? == 0 {
2114 channel_store
2115 .update(cx, |channels, _| channels.remove_channel(channel_id))?
2116 .await
2117 .notify_async_err(cx);
2118 this.update_in(cx, |_, window, cx| cx.focus_self(window))
2119 .ok();
2120 }
2121 anyhow::Ok(())
2122 })
2123 .detach();
2124 }
2125 }
2126
2127 fn remove_contact(
2128 &mut self,
2129 user_id: u64,
2130 github_login: &str,
2131 window: &mut Window,
2132 cx: &mut Context<Self>,
2133 ) {
2134 let user_store = self.user_store.clone();
2135 let prompt_message = format!(
2136 "Are you sure you want to remove \"{}\" from your contacts?",
2137 github_login
2138 );
2139 let answer = window.prompt(
2140 PromptLevel::Warning,
2141 &prompt_message,
2142 None,
2143 &["Remove", "Cancel"],
2144 cx,
2145 );
2146 cx.spawn_in(window, async move |_, cx| {
2147 if answer.await? == 0 {
2148 user_store
2149 .update(cx, |store, cx| store.remove_contact(user_id, cx))?
2150 .await
2151 .notify_async_err(cx);
2152 }
2153 anyhow::Ok(())
2154 })
2155 .detach_and_prompt_err("Failed to remove contact", window, cx, |_, _, _| None);
2156 }
2157
2158 fn respond_to_contact_request(
2159 &mut self,
2160 user_id: u64,
2161 accept: bool,
2162 window: &mut Window,
2163 cx: &mut Context<Self>,
2164 ) {
2165 self.user_store
2166 .update(cx, |store, cx| {
2167 store.respond_to_contact_request(user_id, accept, cx)
2168 })
2169 .detach_and_prompt_err(
2170 "Failed to respond to contact request",
2171 window,
2172 cx,
2173 |_, _, _| None,
2174 );
2175 }
2176
2177 fn respond_to_channel_invite(
2178 &mut self,
2179 channel_id: ChannelId,
2180 accept: bool,
2181 cx: &mut Context<Self>,
2182 ) {
2183 self.channel_store
2184 .update(cx, |store, cx| {
2185 store.respond_to_channel_invite(channel_id, accept, cx)
2186 })
2187 .detach();
2188 }
2189
2190 fn call(&mut self, recipient_user_id: u64, window: &mut Window, cx: &mut Context<Self>) {
2191 ActiveCall::global(cx)
2192 .update(cx, |call, cx| {
2193 call.invite(recipient_user_id, Some(self.project.clone()), cx)
2194 })
2195 .detach_and_prompt_err("Call failed", window, cx, |_, _, _| None);
2196 }
2197
2198 fn join_channel(&self, channel_id: ChannelId, window: &mut Window, cx: &mut Context<Self>) {
2199 let Some(workspace) = self.workspace.upgrade() else {
2200 return;
2201 };
2202 let Some(handle) = window.window_handle().downcast::<Workspace>() else {
2203 return;
2204 };
2205 workspace::join_channel(
2206 channel_id,
2207 workspace.read(cx).app_state().clone(),
2208 Some(handle),
2209 cx,
2210 )
2211 .detach_and_prompt_err("Failed to join channel", window, cx, |_, _, _| None)
2212 }
2213
2214 fn copy_channel_link(&mut self, channel_id: ChannelId, cx: &mut Context<Self>) {
2215 let channel_store = self.channel_store.read(cx);
2216 let Some(channel) = channel_store.channel_for_id(channel_id) else {
2217 return;
2218 };
2219 let item = ClipboardItem::new_string(channel.link(cx));
2220 cx.write_to_clipboard(item)
2221 }
2222
2223 fn render_signed_out(&mut self, cx: &mut Context<Self>) -> Div {
2224 let collab_blurb = "Work with your team in realtime with collaborative editing, voice, shared notes and more.";
2225
2226 v_flex()
2227 .gap_6()
2228 .p_4()
2229 .child(Label::new(collab_blurb))
2230 .child(
2231 v_flex()
2232 .gap_2()
2233 .child(
2234 Button::new("sign_in", "Sign in")
2235 .icon_color(Color::Muted)
2236 .icon(IconName::Github)
2237 .icon_position(IconPosition::Start)
2238 .style(ButtonStyle::Filled)
2239 .full_width()
2240 .on_click(cx.listener(|this, _, window, cx| {
2241 let client = this.client.clone();
2242 cx.spawn_in(window, async move |_, cx| {
2243 client
2244 .connect(true, cx)
2245 .await
2246 .into_response()
2247 .notify_async_err(cx);
2248 })
2249 .detach()
2250 })),
2251 )
2252 .child(
2253 div().flex().w_full().items_center().child(
2254 Label::new("Sign in to enable collaboration.")
2255 .color(Color::Muted)
2256 .size(LabelSize::Small),
2257 ),
2258 ),
2259 )
2260 }
2261
2262 fn render_list_entry(
2263 &mut self,
2264 ix: usize,
2265 window: &mut Window,
2266 cx: &mut Context<Self>,
2267 ) -> AnyElement {
2268 let entry = &self.entries[ix];
2269
2270 let is_selected = self.selection == Some(ix);
2271 match entry {
2272 ListEntry::Header(section) => {
2273 let is_collapsed = self.collapsed_sections.contains(section);
2274 self.render_header(*section, is_selected, is_collapsed, cx)
2275 .into_any_element()
2276 }
2277 ListEntry::Contact { contact, calling } => self
2278 .render_contact(contact, *calling, is_selected, cx)
2279 .into_any_element(),
2280 ListEntry::ContactPlaceholder => self
2281 .render_contact_placeholder(is_selected, cx)
2282 .into_any_element(),
2283 ListEntry::IncomingRequest(user) => self
2284 .render_contact_request(user, true, is_selected, cx)
2285 .into_any_element(),
2286 ListEntry::OutgoingRequest(user) => self
2287 .render_contact_request(user, false, is_selected, cx)
2288 .into_any_element(),
2289 ListEntry::Channel {
2290 channel,
2291 depth,
2292 has_children,
2293 } => self
2294 .render_channel(channel, *depth, *has_children, is_selected, ix, cx)
2295 .into_any_element(),
2296 ListEntry::ChannelEditor { depth } => self
2297 .render_channel_editor(*depth, window, cx)
2298 .into_any_element(),
2299 ListEntry::ChannelInvite(channel) => self
2300 .render_channel_invite(channel, is_selected, cx)
2301 .into_any_element(),
2302 ListEntry::CallParticipant {
2303 user,
2304 peer_id,
2305 is_pending,
2306 role,
2307 } => self
2308 .render_call_participant(user, *peer_id, *is_pending, *role, is_selected, cx)
2309 .into_any_element(),
2310 ListEntry::ParticipantProject {
2311 project_id,
2312 worktree_root_names,
2313 host_user_id,
2314 is_last,
2315 } => self
2316 .render_participant_project(
2317 *project_id,
2318 worktree_root_names,
2319 *host_user_id,
2320 *is_last,
2321 is_selected,
2322 window,
2323 cx,
2324 )
2325 .into_any_element(),
2326 ListEntry::ParticipantScreen { peer_id, is_last } => self
2327 .render_participant_screen(*peer_id, *is_last, is_selected, window, cx)
2328 .into_any_element(),
2329 ListEntry::ChannelNotes { channel_id } => self
2330 .render_channel_notes(*channel_id, is_selected, window, cx)
2331 .into_any_element(),
2332 }
2333 }
2334
2335 fn render_signed_in(&mut self, _: &mut Window, cx: &mut Context<Self>) -> Div {
2336 self.channel_store.update(cx, |channel_store, _| {
2337 channel_store.initialize();
2338 });
2339 v_flex()
2340 .size_full()
2341 .child(
2342 list(
2343 self.list_state.clone(),
2344 cx.processor(Self::render_list_entry),
2345 )
2346 .size_full(),
2347 )
2348 .child(
2349 v_flex()
2350 .child(div().mx_2().border_primary(cx).border_t_1())
2351 .child(
2352 v_flex()
2353 .p_2()
2354 .child(self.render_filter_input(&self.filter_editor, cx)),
2355 ),
2356 )
2357 }
2358
2359 fn render_filter_input(
2360 &self,
2361 editor: &Entity<Editor>,
2362 cx: &mut Context<Self>,
2363 ) -> impl IntoElement {
2364 let settings = ThemeSettings::get_global(cx);
2365 let text_style = TextStyle {
2366 color: if editor.read(cx).read_only(cx) {
2367 cx.theme().colors().text_disabled
2368 } else {
2369 cx.theme().colors().text
2370 },
2371 font_family: settings.ui_font.family.clone(),
2372 font_features: settings.ui_font.features.clone(),
2373 font_fallbacks: settings.ui_font.fallbacks.clone(),
2374 font_size: rems(0.875).into(),
2375 font_weight: settings.ui_font.weight,
2376 font_style: FontStyle::Normal,
2377 line_height: relative(1.3),
2378 ..Default::default()
2379 };
2380
2381 EditorElement::new(
2382 editor,
2383 EditorStyle {
2384 local_player: cx.theme().players().local(),
2385 text: text_style,
2386 ..Default::default()
2387 },
2388 )
2389 }
2390
2391 fn render_header(
2392 &self,
2393 section: Section,
2394 is_selected: bool,
2395 is_collapsed: bool,
2396 cx: &mut Context<Self>,
2397 ) -> impl IntoElement {
2398 let mut channel_link = None;
2399 let mut channel_tooltip_text = None;
2400 let mut channel_icon = None;
2401
2402 let text = match section {
2403 Section::ActiveCall => {
2404 let channel_name = maybe!({
2405 let channel_id = ActiveCall::global(cx).read(cx).channel_id(cx)?;
2406
2407 let channel = self.channel_store.read(cx).channel_for_id(channel_id)?;
2408
2409 channel_link = Some(channel.link(cx));
2410 (channel_icon, channel_tooltip_text) = match channel.visibility {
2411 proto::ChannelVisibility::Public => {
2412 (Some("icons/public.svg"), Some("Copy public channel link."))
2413 }
2414 proto::ChannelVisibility::Members => {
2415 (Some("icons/hash.svg"), Some("Copy private channel link."))
2416 }
2417 };
2418
2419 Some(channel.name.as_ref())
2420 });
2421
2422 if let Some(name) = channel_name {
2423 SharedString::from(name.to_string())
2424 } else {
2425 SharedString::from("Current Call")
2426 }
2427 }
2428 Section::ContactRequests => SharedString::from("Requests"),
2429 Section::Contacts => SharedString::from("Contacts"),
2430 Section::Channels => SharedString::from("Channels"),
2431 Section::ChannelInvites => SharedString::from("Invites"),
2432 Section::Online => SharedString::from("Online"),
2433 Section::Offline => SharedString::from("Offline"),
2434 };
2435
2436 let button = match section {
2437 Section::ActiveCall => channel_link.map(|channel_link| {
2438 let channel_link_copy = channel_link;
2439 IconButton::new("channel-link", IconName::Copy)
2440 .icon_size(IconSize::Small)
2441 .size(ButtonSize::None)
2442 .visible_on_hover("section-header")
2443 .on_click(move |_, _, cx| {
2444 let item = ClipboardItem::new_string(channel_link_copy.clone());
2445 cx.write_to_clipboard(item)
2446 })
2447 .tooltip(Tooltip::text("Copy channel link"))
2448 .into_any_element()
2449 }),
2450 Section::Contacts => Some(
2451 IconButton::new("add-contact", IconName::Plus)
2452 .on_click(
2453 cx.listener(|this, _, window, cx| this.toggle_contact_finder(window, cx)),
2454 )
2455 .tooltip(Tooltip::text("Search for new contact"))
2456 .into_any_element(),
2457 ),
2458 Section::Channels => Some(
2459 IconButton::new("add-channel", IconName::Plus)
2460 .on_click(cx.listener(|this, _, window, cx| this.new_root_channel(window, cx)))
2461 .tooltip(Tooltip::text("Create a channel"))
2462 .into_any_element(),
2463 ),
2464 _ => None,
2465 };
2466
2467 let can_collapse = match section {
2468 Section::ActiveCall | Section::Channels | Section::Contacts => false,
2469 Section::ChannelInvites
2470 | Section::ContactRequests
2471 | Section::Online
2472 | Section::Offline => true,
2473 };
2474
2475 h_flex().w_full().group("section-header").child(
2476 ListHeader::new(text)
2477 .when(can_collapse, |header| {
2478 header.toggle(Some(!is_collapsed)).on_toggle(cx.listener(
2479 move |this, _, _, cx| {
2480 this.toggle_section_expanded(section, cx);
2481 },
2482 ))
2483 })
2484 .inset(true)
2485 .end_slot::<AnyElement>(button)
2486 .toggle_state(is_selected),
2487 )
2488 }
2489
2490 fn render_contact(
2491 &self,
2492 contact: &Arc<Contact>,
2493 calling: bool,
2494 is_selected: bool,
2495 cx: &mut Context<Self>,
2496 ) -> impl IntoElement {
2497 let online = contact.online;
2498 let busy = contact.busy || calling;
2499 let github_login = contact.user.github_login.clone();
2500 let item = ListItem::new(github_login.clone())
2501 .indent_level(1)
2502 .indent_step_size(px(20.))
2503 .toggle_state(is_selected)
2504 .child(
2505 h_flex()
2506 .w_full()
2507 .justify_between()
2508 .child(Label::new(github_login.clone()))
2509 .when(calling, |el| {
2510 el.child(Label::new("Calling").color(Color::Muted))
2511 })
2512 .when(!calling, |el| {
2513 el.child(
2514 IconButton::new("contact context menu", IconName::Ellipsis)
2515 .icon_color(Color::Muted)
2516 .visible_on_hover("")
2517 .on_click(cx.listener({
2518 let contact = contact.clone();
2519 move |this, event: &ClickEvent, window, cx| {
2520 this.deploy_contact_context_menu(
2521 event.position(),
2522 contact.clone(),
2523 window,
2524 cx,
2525 );
2526 }
2527 })),
2528 )
2529 }),
2530 )
2531 .on_secondary_mouse_down(cx.listener({
2532 let contact = contact.clone();
2533 move |this, event: &MouseDownEvent, window, cx| {
2534 this.deploy_contact_context_menu(event.position, contact.clone(), window, cx);
2535 }
2536 }))
2537 .start_slot(
2538 // todo handle contacts with no avatar
2539 Avatar::new(contact.user.avatar_uri.clone())
2540 .indicator::<AvatarAvailabilityIndicator>(if online {
2541 Some(AvatarAvailabilityIndicator::new(match busy {
2542 true => ui::CollaboratorAvailability::Busy,
2543 false => ui::CollaboratorAvailability::Free,
2544 }))
2545 } else {
2546 None
2547 }),
2548 );
2549
2550 div()
2551 .id(github_login.clone())
2552 .group("")
2553 .child(item)
2554 .tooltip(move |_, cx| {
2555 let text = if !online {
2556 format!(" {} is offline", &github_login)
2557 } else if busy {
2558 format!(" {} is on a call", &github_login)
2559 } else {
2560 let room = ActiveCall::global(cx).read(cx).room();
2561 if room.is_some() {
2562 format!("Invite {} to join call", &github_login)
2563 } else {
2564 format!("Call {}", &github_login)
2565 }
2566 };
2567 Tooltip::simple(text, cx)
2568 })
2569 }
2570
2571 fn render_contact_request(
2572 &self,
2573 user: &Arc<User>,
2574 is_incoming: bool,
2575 is_selected: bool,
2576 cx: &mut Context<Self>,
2577 ) -> impl IntoElement {
2578 let github_login = user.github_login.clone();
2579 let user_id = user.id;
2580 let is_response_pending = self.user_store.read(cx).is_contact_request_pending(user);
2581 let color = if is_response_pending {
2582 Color::Muted
2583 } else {
2584 Color::Default
2585 };
2586
2587 let controls = if is_incoming {
2588 vec![
2589 IconButton::new("decline-contact", IconName::Close)
2590 .on_click(cx.listener(move |this, _, window, cx| {
2591 this.respond_to_contact_request(user_id, false, window, cx);
2592 }))
2593 .icon_color(color)
2594 .tooltip(Tooltip::text("Decline invite")),
2595 IconButton::new("accept-contact", IconName::Check)
2596 .on_click(cx.listener(move |this, _, window, cx| {
2597 this.respond_to_contact_request(user_id, true, window, cx);
2598 }))
2599 .icon_color(color)
2600 .tooltip(Tooltip::text("Accept invite")),
2601 ]
2602 } else {
2603 let github_login = github_login.clone();
2604 vec![
2605 IconButton::new("remove_contact", IconName::Close)
2606 .on_click(cx.listener(move |this, _, window, cx| {
2607 this.remove_contact(user_id, &github_login, window, cx);
2608 }))
2609 .icon_color(color)
2610 .tooltip(Tooltip::text("Cancel invite")),
2611 ]
2612 };
2613
2614 ListItem::new(github_login.clone())
2615 .indent_level(1)
2616 .indent_step_size(px(20.))
2617 .toggle_state(is_selected)
2618 .child(
2619 h_flex()
2620 .w_full()
2621 .justify_between()
2622 .child(Label::new(github_login))
2623 .child(h_flex().children(controls)),
2624 )
2625 .start_slot(Avatar::new(user.avatar_uri.clone()))
2626 }
2627
2628 fn render_channel_invite(
2629 &self,
2630 channel: &Arc<Channel>,
2631 is_selected: bool,
2632 cx: &mut Context<Self>,
2633 ) -> ListItem {
2634 let channel_id = channel.id;
2635 let response_is_pending = self
2636 .channel_store
2637 .read(cx)
2638 .has_pending_channel_invite_response(channel);
2639 let color = if response_is_pending {
2640 Color::Muted
2641 } else {
2642 Color::Default
2643 };
2644
2645 let controls = [
2646 IconButton::new("reject-invite", IconName::Close)
2647 .on_click(cx.listener(move |this, _, _, cx| {
2648 this.respond_to_channel_invite(channel_id, false, cx);
2649 }))
2650 .icon_color(color)
2651 .tooltip(Tooltip::text("Decline invite")),
2652 IconButton::new("accept-invite", IconName::Check)
2653 .on_click(cx.listener(move |this, _, _, cx| {
2654 this.respond_to_channel_invite(channel_id, true, cx);
2655 }))
2656 .icon_color(color)
2657 .tooltip(Tooltip::text("Accept invite")),
2658 ];
2659
2660 ListItem::new(("channel-invite", channel.id.0 as usize))
2661 .toggle_state(is_selected)
2662 .child(
2663 h_flex()
2664 .w_full()
2665 .justify_between()
2666 .child(Label::new(channel.name.clone()))
2667 .child(h_flex().children(controls)),
2668 )
2669 .start_slot(
2670 Icon::new(IconName::Hash)
2671 .size(IconSize::Small)
2672 .color(Color::Muted),
2673 )
2674 }
2675
2676 fn render_contact_placeholder(&self, is_selected: bool, cx: &mut Context<Self>) -> ListItem {
2677 ListItem::new("contact-placeholder")
2678 .child(Icon::new(IconName::Plus))
2679 .child(Label::new("Add a Contact"))
2680 .toggle_state(is_selected)
2681 .on_click(cx.listener(|this, _, window, cx| this.toggle_contact_finder(window, cx)))
2682 }
2683
2684 fn render_channel(
2685 &self,
2686 channel: &Channel,
2687 depth: usize,
2688 has_children: bool,
2689 is_selected: bool,
2690 ix: usize,
2691 cx: &mut Context<Self>,
2692 ) -> impl IntoElement {
2693 let channel_id = channel.id;
2694
2695 let is_active = maybe!({
2696 let call_channel = ActiveCall::global(cx)
2697 .read(cx)
2698 .room()?
2699 .read(cx)
2700 .channel_id()?;
2701 Some(call_channel == channel_id)
2702 })
2703 .unwrap_or(false);
2704 let channel_store = self.channel_store.read(cx);
2705 let is_public = channel_store
2706 .channel_for_id(channel_id)
2707 .map(|channel| channel.visibility)
2708 == Some(proto::ChannelVisibility::Public);
2709 let disclosed =
2710 has_children.then(|| self.collapsed_channels.binary_search(&channel.id).is_err());
2711
2712 let has_notes_notification = channel_store.has_channel_buffer_changed(channel_id);
2713
2714 const FACEPILE_LIMIT: usize = 3;
2715 let participants = self.channel_store.read(cx).channel_participants(channel_id);
2716
2717 let face_pile = if participants.is_empty() {
2718 None
2719 } else {
2720 let extra_count = participants.len().saturating_sub(FACEPILE_LIMIT);
2721 let result = Facepile::new(
2722 participants
2723 .iter()
2724 .map(|user| Avatar::new(user.avatar_uri.clone()).into_any_element())
2725 .take(FACEPILE_LIMIT)
2726 .chain(if extra_count > 0 {
2727 Some(
2728 Label::new(format!("+{extra_count}"))
2729 .ml_2()
2730 .into_any_element(),
2731 )
2732 } else {
2733 None
2734 })
2735 .collect::<SmallVec<_>>(),
2736 );
2737
2738 Some(result)
2739 };
2740
2741 let width = self.width.unwrap_or(px(240.));
2742 let root_id = channel.root_id();
2743
2744 div()
2745 .h_6()
2746 .id(channel_id.0 as usize)
2747 .group("")
2748 .flex()
2749 .w_full()
2750 .when(!channel.is_root_channel(), |el| {
2751 el.on_drag(channel.clone(), move |channel, _, _, cx| {
2752 cx.new(|_| DraggedChannelView {
2753 channel: channel.clone(),
2754 width,
2755 })
2756 })
2757 })
2758 .drag_over::<Channel>({
2759 move |style, dragged_channel: &Channel, _window, cx| {
2760 if dragged_channel.root_id() == root_id {
2761 style.bg(cx.theme().colors().ghost_element_hover)
2762 } else {
2763 style
2764 }
2765 }
2766 })
2767 .on_drop(
2768 cx.listener(move |this, dragged_channel: &Channel, window, cx| {
2769 if dragged_channel.root_id() != root_id {
2770 return;
2771 }
2772 this.move_channel(dragged_channel.id, channel_id, window, cx);
2773 }),
2774 )
2775 .child(
2776 ListItem::new(channel_id.0 as usize)
2777 // Add one level of depth for the disclosure arrow.
2778 .indent_level(depth + 1)
2779 .indent_step_size(px(20.))
2780 .toggle_state(is_selected || is_active)
2781 .toggle(disclosed)
2782 .on_toggle(cx.listener(move |this, _, window, cx| {
2783 this.toggle_channel_collapsed(channel_id, window, cx)
2784 }))
2785 .on_click(cx.listener(move |this, _, window, cx| {
2786 if is_active {
2787 this.open_channel_notes(channel_id, window, cx)
2788 } else {
2789 this.join_channel(channel_id, window, cx)
2790 }
2791 }))
2792 .on_secondary_mouse_down(cx.listener(
2793 move |this, event: &MouseDownEvent, window, cx| {
2794 this.deploy_channel_context_menu(
2795 event.position,
2796 channel_id,
2797 ix,
2798 window,
2799 cx,
2800 )
2801 },
2802 ))
2803 .start_slot(
2804 div()
2805 .relative()
2806 .child(
2807 Icon::new(if is_public {
2808 IconName::Public
2809 } else {
2810 IconName::Hash
2811 })
2812 .size(IconSize::Small)
2813 .color(Color::Muted),
2814 )
2815 .children(has_notes_notification.then(|| {
2816 div()
2817 .w_1p5()
2818 .absolute()
2819 .right(px(-1.))
2820 .top(px(-1.))
2821 .child(Indicator::dot().color(Color::Info))
2822 })),
2823 )
2824 .child(
2825 h_flex()
2826 .id(channel_id.0 as usize)
2827 .child(Label::new(channel.name.clone()))
2828 .children(face_pile.map(|face_pile| face_pile.p_1())),
2829 ),
2830 )
2831 .child(
2832 h_flex().absolute().right(rems(0.)).h_full().child(
2833 h_flex()
2834 .h_full()
2835 .bg(cx.theme().colors().background)
2836 .rounded_l_sm()
2837 .gap_1()
2838 .px_1()
2839 .child(
2840 IconButton::new("channel_notes", IconName::Reader)
2841 .style(ButtonStyle::Filled)
2842 .shape(ui::IconButtonShape::Square)
2843 .icon_size(IconSize::Small)
2844 .icon_color(if has_notes_notification {
2845 Color::Default
2846 } else {
2847 Color::Muted
2848 })
2849 .on_click(cx.listener(move |this, _, window, cx| {
2850 this.open_channel_notes(channel_id, window, cx)
2851 }))
2852 .tooltip(Tooltip::text("Open channel notes")),
2853 )
2854 .visible_on_hover(""),
2855 ),
2856 )
2857 .tooltip({
2858 let channel_store = self.channel_store.clone();
2859 move |_window, cx| {
2860 cx.new(|_| JoinChannelTooltip {
2861 channel_store: channel_store.clone(),
2862 channel_id,
2863 has_notes_notification,
2864 })
2865 .into()
2866 }
2867 })
2868 }
2869
2870 fn render_channel_editor(
2871 &self,
2872 depth: usize,
2873 _window: &mut Window,
2874 _cx: &mut Context<Self>,
2875 ) -> impl IntoElement {
2876 let item = ListItem::new("channel-editor")
2877 .inset(false)
2878 // Add one level of depth for the disclosure arrow.
2879 .indent_level(depth + 1)
2880 .indent_step_size(px(20.))
2881 .start_slot(
2882 Icon::new(IconName::Hash)
2883 .size(IconSize::Small)
2884 .color(Color::Muted),
2885 );
2886
2887 if let Some(pending_name) = self
2888 .channel_editing_state
2889 .as_ref()
2890 .and_then(|state| state.pending_name())
2891 {
2892 item.child(Label::new(pending_name))
2893 } else {
2894 item.child(self.channel_name_editor.clone())
2895 }
2896 }
2897}
2898
2899fn render_tree_branch(
2900 is_last: bool,
2901 overdraw: bool,
2902 window: &mut Window,
2903 cx: &mut App,
2904) -> impl IntoElement {
2905 let rem_size = window.rem_size();
2906 let line_height = window.text_style().line_height_in_pixels(rem_size);
2907 let width = rem_size * 1.5;
2908 let thickness = px(1.);
2909 let color = cx.theme().colors().text;
2910
2911 canvas(
2912 |_, _, _| {},
2913 move |bounds, _, window, _| {
2914 let start_x = (bounds.left() + bounds.right() - thickness) / 2.;
2915 let start_y = (bounds.top() + bounds.bottom() - thickness) / 2.;
2916 let right = bounds.right();
2917 let top = bounds.top();
2918
2919 window.paint_quad(fill(
2920 Bounds::from_corners(
2921 point(start_x, top),
2922 point(
2923 start_x + thickness,
2924 if is_last {
2925 start_y
2926 } else {
2927 bounds.bottom() + if overdraw { px(1.) } else { px(0.) }
2928 },
2929 ),
2930 ),
2931 color,
2932 ));
2933 window.paint_quad(fill(
2934 Bounds::from_corners(point(start_x, start_y), point(right, start_y + thickness)),
2935 color,
2936 ));
2937 },
2938 )
2939 .w(width)
2940 .h(line_height)
2941}
2942
2943impl Render for CollabPanel {
2944 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
2945 v_flex()
2946 .key_context(self.dispatch_context(window, cx))
2947 .on_action(cx.listener(CollabPanel::cancel))
2948 .on_action(cx.listener(CollabPanel::select_next))
2949 .on_action(cx.listener(CollabPanel::select_previous))
2950 .on_action(cx.listener(CollabPanel::confirm))
2951 .on_action(cx.listener(CollabPanel::insert_space))
2952 .on_action(cx.listener(CollabPanel::remove_selected_channel))
2953 .on_action(cx.listener(CollabPanel::show_inline_context_menu))
2954 .on_action(cx.listener(CollabPanel::rename_selected_channel))
2955 .on_action(cx.listener(CollabPanel::collapse_selected_channel))
2956 .on_action(cx.listener(CollabPanel::expand_selected_channel))
2957 .on_action(cx.listener(CollabPanel::start_move_selected_channel))
2958 .on_action(cx.listener(CollabPanel::move_channel_up))
2959 .on_action(cx.listener(CollabPanel::move_channel_down))
2960 .track_focus(&self.focus_handle)
2961 .size_full()
2962 .child(if !self.client.status().borrow().is_or_was_connected() {
2963 self.render_signed_out(cx)
2964 } else {
2965 self.render_signed_in(window, cx)
2966 })
2967 .children(self.context_menu.as_ref().map(|(menu, position, _)| {
2968 deferred(
2969 anchored()
2970 .position(*position)
2971 .anchor(gpui::Corner::TopLeft)
2972 .child(menu.clone()),
2973 )
2974 .with_priority(1)
2975 }))
2976 }
2977}
2978
2979impl EventEmitter<PanelEvent> for CollabPanel {}
2980
2981impl Panel for CollabPanel {
2982 fn position(&self, _window: &Window, cx: &App) -> DockPosition {
2983 CollaborationPanelSettings::get_global(cx).dock
2984 }
2985
2986 fn position_is_valid(&self, position: DockPosition) -> bool {
2987 matches!(position, DockPosition::Left | DockPosition::Right)
2988 }
2989
2990 fn set_position(
2991 &mut self,
2992 position: DockPosition,
2993 _window: &mut Window,
2994 cx: &mut Context<Self>,
2995 ) {
2996 settings::update_settings_file::<CollaborationPanelSettings>(
2997 self.fs.clone(),
2998 cx,
2999 move |settings, _| settings.dock = Some(position),
3000 );
3001 }
3002
3003 fn size(&self, _window: &Window, cx: &App) -> Pixels {
3004 self.width
3005 .unwrap_or_else(|| CollaborationPanelSettings::get_global(cx).default_width)
3006 }
3007
3008 fn set_size(&mut self, size: Option<Pixels>, window: &mut Window, cx: &mut Context<Self>) {
3009 self.width = size;
3010 cx.notify();
3011 cx.defer_in(window, |this, _, cx| {
3012 this.serialize(cx);
3013 });
3014 }
3015
3016 fn icon(&self, _window: &Window, cx: &App) -> Option<ui::IconName> {
3017 CollaborationPanelSettings::get_global(cx)
3018 .button
3019 .then_some(ui::IconName::UserGroup)
3020 }
3021
3022 fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> {
3023 Some("Collab Panel")
3024 }
3025
3026 fn toggle_action(&self) -> Box<dyn gpui::Action> {
3027 Box::new(ToggleFocus)
3028 }
3029
3030 fn persistent_name() -> &'static str {
3031 "CollabPanel"
3032 }
3033
3034 fn activation_priority(&self) -> u32 {
3035 6
3036 }
3037}
3038
3039impl Focusable for CollabPanel {
3040 fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
3041 self.filter_editor.focus_handle(cx)
3042 }
3043}
3044
3045impl PartialEq for ListEntry {
3046 fn eq(&self, other: &Self) -> bool {
3047 match self {
3048 ListEntry::Header(section_1) => {
3049 if let ListEntry::Header(section_2) = other {
3050 return section_1 == section_2;
3051 }
3052 }
3053 ListEntry::CallParticipant { user: user_1, .. } => {
3054 if let ListEntry::CallParticipant { user: user_2, .. } = other {
3055 return user_1.id == user_2.id;
3056 }
3057 }
3058 ListEntry::ParticipantProject {
3059 project_id: project_id_1,
3060 ..
3061 } => {
3062 if let ListEntry::ParticipantProject {
3063 project_id: project_id_2,
3064 ..
3065 } = other
3066 {
3067 return project_id_1 == project_id_2;
3068 }
3069 }
3070 ListEntry::ParticipantScreen {
3071 peer_id: peer_id_1, ..
3072 } => {
3073 if let ListEntry::ParticipantScreen {
3074 peer_id: peer_id_2, ..
3075 } = other
3076 {
3077 return peer_id_1 == peer_id_2;
3078 }
3079 }
3080 ListEntry::Channel {
3081 channel: channel_1, ..
3082 } => {
3083 if let ListEntry::Channel {
3084 channel: channel_2, ..
3085 } = other
3086 {
3087 return channel_1.id == channel_2.id;
3088 }
3089 }
3090 ListEntry::ChannelNotes { channel_id } => {
3091 if let ListEntry::ChannelNotes {
3092 channel_id: other_id,
3093 } = other
3094 {
3095 return channel_id == other_id;
3096 }
3097 }
3098 ListEntry::ChannelInvite(channel_1) => {
3099 if let ListEntry::ChannelInvite(channel_2) = other {
3100 return channel_1.id == channel_2.id;
3101 }
3102 }
3103 ListEntry::IncomingRequest(user_1) => {
3104 if let ListEntry::IncomingRequest(user_2) = other {
3105 return user_1.id == user_2.id;
3106 }
3107 }
3108 ListEntry::OutgoingRequest(user_1) => {
3109 if let ListEntry::OutgoingRequest(user_2) = other {
3110 return user_1.id == user_2.id;
3111 }
3112 }
3113 ListEntry::Contact {
3114 contact: contact_1, ..
3115 } => {
3116 if let ListEntry::Contact {
3117 contact: contact_2, ..
3118 } = other
3119 {
3120 return contact_1.user.id == contact_2.user.id;
3121 }
3122 }
3123 ListEntry::ChannelEditor { depth } => {
3124 if let ListEntry::ChannelEditor { depth: other_depth } = other {
3125 return depth == other_depth;
3126 }
3127 }
3128 ListEntry::ContactPlaceholder => {
3129 if let ListEntry::ContactPlaceholder = other {
3130 return true;
3131 }
3132 }
3133 }
3134 false
3135 }
3136}
3137
3138struct DraggedChannelView {
3139 channel: Channel,
3140 width: Pixels,
3141}
3142
3143impl Render for DraggedChannelView {
3144 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
3145 let ui_font = ThemeSettings::get_global(cx).ui_font.family.clone();
3146 h_flex()
3147 .font_family(ui_font)
3148 .bg(cx.theme().colors().background)
3149 .w(self.width)
3150 .p_1()
3151 .gap_1()
3152 .child(
3153 Icon::new(
3154 if self.channel.visibility == proto::ChannelVisibility::Public {
3155 IconName::Public
3156 } else {
3157 IconName::Hash
3158 },
3159 )
3160 .size(IconSize::Small)
3161 .color(Color::Muted),
3162 )
3163 .child(Label::new(self.channel.name.clone()))
3164 }
3165}
3166
3167struct JoinChannelTooltip {
3168 channel_store: Entity<ChannelStore>,
3169 channel_id: ChannelId,
3170 #[allow(unused)]
3171 has_notes_notification: bool,
3172}
3173
3174impl Render for JoinChannelTooltip {
3175 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
3176 tooltip_container(window, cx, |container, _, cx| {
3177 let participants = self
3178 .channel_store
3179 .read(cx)
3180 .channel_participants(self.channel_id);
3181
3182 container
3183 .child(Label::new("Join channel"))
3184 .children(participants.iter().map(|participant| {
3185 h_flex()
3186 .gap_2()
3187 .child(Avatar::new(participant.avatar_uri.clone()))
3188 .child(Label::new(participant.github_login.clone()))
3189 }))
3190 })
3191 }
3192}