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