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, Notification, User, UserStore};
10use collections::{HashMap, HashSet};
11use contact_finder::ContactFinder;
12use db::kvp::KeyValueStore;
13use editor::{Editor, EditorElement, EditorStyle};
14use fuzzy::{StringMatch, StringMatchCandidate, match_strings};
15use gpui::{
16 AnyElement, App, AsyncWindowContext, Bounds, ClickEvent, ClipboardItem, DismissEvent, Div,
17 Empty, Entity, EventEmitter, FocusHandle, Focusable, FontStyle, KeyContext, ListOffset,
18 ListState, MouseDownEvent, Pixels, Point, PromptLevel, SharedString, Subscription, Task,
19 TextStyle, WeakEntity, Window, actions, anchored, canvas, deferred, div, fill, list, point,
20 prelude::*, px,
21};
22
23use menu::{Cancel, Confirm, SecondaryConfirm, SelectNext, SelectPrevious};
24use notifications::{NotificationEntry, NotificationEvent, NotificationStore};
25use project::{Fs, Project};
26use rpc::{
27 ErrorCode, ErrorExt,
28 proto::{self, ChannelVisibility, PeerId, reorder_channel::Direction},
29};
30use serde::{Deserialize, Serialize};
31use settings::Settings;
32use smallvec::SmallVec;
33use std::{mem, sync::Arc, time::Duration};
34use theme::ActiveTheme;
35use theme_settings::ThemeSettings;
36use ui::{
37 Avatar, AvatarAvailabilityIndicator, CollabNotification, ContextMenu, CopyButton, Facepile,
38 HighlightedLabel, IconButtonShape, Indicator, ListHeader, ListItem, Tab, Tooltip, prelude::*,
39 tooltip_container,
40};
41use util::{ResultExt, TryFutureExt, maybe};
42use workspace::{
43 CopyRoomId, Deafen, LeaveCall, MultiWorkspace, Mute, OpenChannelNotes, OpenChannelNotesById,
44 ScreenShare, ShareProject, Workspace,
45 dock::{DockPosition, Panel, PanelEvent},
46 notifications::{
47 DetachAndPromptErr, Notification as WorkspaceNotification, NotificationId, NotifyResultExt,
48 SuppressEvent,
49 },
50};
51
52const FILTER_OCCUPIED_CHANNELS_KEY: &str = "filter_occupied_channels";
53const FAVORITE_CHANNELS_KEY: &str = "favorite_channels";
54
55actions!(
56 collab_panel,
57 [
58 /// Toggles the collab panel.
59 Toggle,
60 /// Toggles focus on the collaboration panel.
61 ToggleFocus,
62 /// Removes the selected channel or contact.
63 Remove,
64 /// Opens the context menu for the selected item.
65 Secondary,
66 /// Collapses the selected channel in the tree view.
67 CollapseSelectedChannel,
68 /// Expands the selected channel in the tree view.
69 ExpandSelectedChannel,
70 /// Opens the meeting notes for the selected channel in the panel.
71 ///
72 /// Use `collab::OpenChannelNotes` to open the channel notes for the current call.
73 OpenSelectedChannelNotes,
74 /// Toggles whether the selected channel is in the Favorites section.
75 ToggleSelectedChannelFavorite,
76 /// Starts moving a channel to a new location.
77 StartMoveChannel,
78 /// Moves the selected item to the current location.
79 MoveSelected,
80 /// Inserts a space character in the filter input.
81 InsertSpace,
82 /// Moves the selected channel up in the list.
83 MoveChannelUp,
84 /// Moves the selected channel down in the list.
85 MoveChannelDown,
86 ]
87);
88
89#[derive(Debug, Copy, Clone, PartialEq, Eq)]
90struct ChannelMoveClipboard {
91 channel_id: ChannelId,
92}
93
94const COLLABORATION_PANEL_KEY: &str = "CollaborationPanel";
95const TOAST_DURATION: Duration = Duration::from_secs(5);
96
97pub fn init(cx: &mut App) {
98 cx.observe_new(|workspace: &mut Workspace, _, _| {
99 workspace.register_action(|workspace, _: &ToggleFocus, window, cx| {
100 workspace.toggle_panel_focus::<CollabPanel>(window, cx);
101 if let Some(collab_panel) = workspace.panel::<CollabPanel>(cx) {
102 collab_panel.update(cx, |panel, cx| {
103 panel.filter_editor.update(cx, |editor, cx| {
104 if editor.snapshot(window, cx).is_focused() {
105 editor.select_all(&Default::default(), window, cx);
106 }
107 });
108 })
109 }
110 });
111 workspace.register_action(|workspace, _: &Toggle, window, cx| {
112 if !workspace.toggle_panel_focus::<CollabPanel>(window, cx) {
113 workspace.close_panel::<CollabPanel>(window, cx);
114 }
115 });
116 workspace.register_action(|_, _: &OpenChannelNotes, window, cx| {
117 let channel_id = ActiveCall::global(cx)
118 .read(cx)
119 .room()
120 .and_then(|room| room.read(cx).channel_id());
121
122 if let Some(channel_id) = channel_id {
123 let workspace = cx.entity();
124 window.defer(cx, move |window, cx| {
125 ChannelView::open(channel_id, None, workspace, window, cx)
126 .detach_and_log_err(cx)
127 });
128 }
129 });
130 workspace.register_action(|_, action: &OpenChannelNotesById, window, cx| {
131 let channel_id = client::ChannelId(action.channel_id);
132 let workspace = cx.entity();
133 window.defer(cx, move |window, cx| {
134 ChannelView::open(channel_id, None, workspace, window, cx).detach_and_log_err(cx)
135 });
136 });
137 // TODO: make it possible to bind this one to a held key for push to talk?
138 // how to make "toggle_on_modifiers_press" contextual?
139 workspace.register_action(|_, _: &Mute, _, cx| title_bar::collab::toggle_mute(cx));
140 workspace.register_action(|_, _: &Deafen, _, cx| title_bar::collab::toggle_deafen(cx));
141 workspace.register_action(|_, _: &LeaveCall, window, cx| {
142 CollabPanel::leave_call(window, cx);
143 });
144 workspace.register_action(|workspace, _: &CopyRoomId, window, cx| {
145 use workspace::notifications::{NotificationId, NotifyTaskExt as _};
146
147 struct RoomIdCopiedToast;
148
149 if let Some(room) = ActiveCall::global(cx).read(cx).room() {
150 let romo_id_fut = room.read(cx).room_id();
151 let workspace_handle = cx.weak_entity();
152 cx.spawn(async move |workspace, cx| {
153 let room_id = romo_id_fut.await.context("Failed to get livekit room")?;
154 workspace.update(cx, |workspace, cx| {
155 cx.write_to_clipboard(ClipboardItem::new_string(room_id));
156 workspace.show_toast(
157 workspace::Toast::new(
158 NotificationId::unique::<RoomIdCopiedToast>(),
159 "Room ID copied to clipboard",
160 )
161 .autohide(),
162 cx,
163 );
164 })
165 })
166 .detach_and_notify_err(workspace_handle, window, cx);
167 } else {
168 workspace.show_error(&"There’s no active call; join one first.", cx);
169 }
170 });
171 workspace.register_action(|workspace, _: &ShareProject, window, cx| {
172 let project = workspace.project().clone();
173 println!("{project:?}");
174 window.defer(cx, move |_window, cx| {
175 ActiveCall::global(cx).update(cx, move |call, cx| {
176 if let Some(room) = call.room() {
177 println!("{room:?}");
178 if room.read(cx).is_sharing_project() {
179 call.unshare_project(project, cx).ok();
180 } else {
181 call.share_project(project, cx).detach_and_log_err(cx);
182 }
183 }
184 });
185 });
186 });
187 // TODO(jk): Is this action ever triggered?
188 workspace.register_action(|_, _: &ScreenShare, window, cx| {
189 let room = ActiveCall::global(cx).read(cx).room().cloned();
190 if let Some(room) = room {
191 window.defer(cx, move |_window, cx| {
192 room.update(cx, |room, cx| {
193 if room.is_sharing_screen() {
194 room.unshare_screen(true, cx).ok();
195 } else {
196 #[cfg(target_os = "linux")]
197 let is_wayland = gpui::guess_compositor() == "Wayland";
198 #[cfg(not(target_os = "linux"))]
199 let is_wayland = false;
200
201 #[cfg(target_os = "linux")]
202 {
203 if is_wayland {
204 room.share_screen_wayland(cx).detach_and_log_err(cx);
205 }
206 }
207 if !is_wayland {
208 let sources = cx.screen_capture_sources();
209
210 cx.spawn(async move |room, cx| {
211 let sources = sources.await??;
212 let first = sources.into_iter().next();
213 if let Some(first) = first {
214 room.update(cx, |room, cx| room.share_screen(first, cx))?
215 .await
216 } else {
217 Ok(())
218 }
219 })
220 .detach_and_log_err(cx);
221 }
222 };
223 });
224 });
225 }
226 });
227 })
228 .detach();
229}
230
231#[derive(Debug)]
232pub enum ChannelEditingState {
233 Create {
234 location: Option<ChannelId>,
235 pending_name: Option<String>,
236 },
237 Rename {
238 location: ChannelId,
239 pending_name: Option<String>,
240 },
241}
242
243impl ChannelEditingState {
244 fn pending_name(&self) -> Option<String> {
245 match self {
246 ChannelEditingState::Create { pending_name, .. } => pending_name.clone(),
247 ChannelEditingState::Rename { pending_name, .. } => pending_name.clone(),
248 }
249 }
250}
251
252pub struct CollabPanel {
253 fs: Arc<dyn Fs>,
254 focus_handle: FocusHandle,
255 channel_clipboard: Option<ChannelMoveClipboard>,
256 pending_panel_serialization: Task<Option<()>>,
257 pending_favorites_serialization: Task<Option<()>>,
258 pending_filter_serialization: Task<Option<()>>,
259 context_menu: Option<(Entity<ContextMenu>, Point<Pixels>, Subscription)>,
260 list_state: ListState,
261 filter_editor: Entity<Editor>,
262 channel_name_editor: Entity<Editor>,
263 channel_editing_state: Option<ChannelEditingState>,
264 entries: Vec<ListEntry>,
265 selection: Option<usize>,
266 channel_store: Entity<ChannelStore>,
267 user_store: Entity<UserStore>,
268 client: Arc<Client>,
269 project: Entity<Project>,
270 match_candidates: Vec<StringMatchCandidate>,
271 subscriptions: Vec<Subscription>,
272 collapsed_sections: Vec<Section>,
273 collapsed_channels: Vec<ChannelId>,
274 filter_occupied_channels: bool,
275 workspace: WeakEntity<Workspace>,
276 notification_store: Entity<NotificationStore>,
277 current_notification_toast: Option<(u64, Task<()>)>,
278 mark_as_read_tasks: HashMap<u64, Task<anyhow::Result<()>>>,
279}
280
281#[derive(Serialize, Deserialize)]
282struct SerializedCollabPanel {
283 collapsed_channels: Option<Vec<u64>>,
284}
285
286#[derive(Clone, Copy, PartialEq, Eq, Debug, PartialOrd, Ord)]
287enum Section {
288 ActiveCall,
289 FavoriteChannels,
290 Channels,
291 ChannelInvites,
292 ContactRequests,
293 Contacts,
294 Online,
295 Offline,
296}
297
298#[derive(Clone, Debug)]
299enum ListEntry {
300 Header(Section),
301 CallParticipant {
302 user: Arc<User>,
303 peer_id: Option<PeerId>,
304 is_pending: bool,
305 role: proto::ChannelRole,
306 },
307 ParticipantProject {
308 project_id: u64,
309 worktree_root_names: Vec<String>,
310 host_user_id: u64,
311 is_last: bool,
312 },
313 ParticipantScreen {
314 peer_id: Option<PeerId>,
315 is_last: bool,
316 },
317 IncomingRequest(Arc<User>),
318 OutgoingRequest(Arc<User>),
319 ChannelInvite(Arc<Channel>),
320 Channel {
321 channel: Arc<Channel>,
322 depth: usize,
323 has_children: bool,
324 is_favorite: bool,
325 // `None` when the channel is a parent of a matched channel.
326 string_match: Option<StringMatch>,
327 },
328 ChannelNotes {
329 channel_id: ChannelId,
330 },
331 ChannelEditor {
332 depth: usize,
333 },
334 Contact {
335 contact: Arc<Contact>,
336 calling: bool,
337 },
338 ContactPlaceholder,
339}
340
341impl CollabPanel {
342 pub fn new(
343 workspace: &mut Workspace,
344 window: &mut Window,
345 cx: &mut Context<Workspace>,
346 ) -> Entity<Self> {
347 cx.new(|cx| {
348 let filter_editor = cx.new(|cx| {
349 let mut editor = Editor::single_line(window, cx);
350 editor.set_placeholder_text("Search channels…", window, cx);
351 editor
352 });
353
354 cx.subscribe(&filter_editor, |this: &mut Self, _, event, cx| {
355 if let editor::EditorEvent::BufferEdited = event {
356 let query = this.filter_editor.read(cx).text(cx);
357 if !query.is_empty() {
358 this.selection.take();
359 }
360 this.update_entries(true, cx);
361 if !query.is_empty() {
362 this.selection = this
363 .entries
364 .iter()
365 .position(|entry| !matches!(entry, ListEntry::Header(_)));
366 }
367 }
368 })
369 .detach();
370
371 let channel_name_editor = cx.new(|cx| Editor::single_line(window, cx));
372
373 cx.subscribe_in(
374 &channel_name_editor,
375 window,
376 |this: &mut Self, _, event, window, cx| {
377 if let editor::EditorEvent::Blurred = event {
378 if let Some(state) = &this.channel_editing_state
379 && state.pending_name().is_some()
380 {
381 return;
382 }
383 this.take_editing_state(window, cx);
384 this.update_entries(false, cx);
385 cx.notify();
386 }
387 },
388 )
389 .detach();
390
391 let mut this = Self {
392 focus_handle: cx.focus_handle(),
393 channel_clipboard: None,
394 fs: workspace.app_state().fs.clone(),
395 pending_panel_serialization: Task::ready(None),
396 pending_favorites_serialization: Task::ready(None),
397 pending_filter_serialization: Task::ready(None),
398 context_menu: None,
399 list_state: ListState::new(0, gpui::ListAlignment::Top, px(1000.)),
400 channel_name_editor,
401 filter_editor,
402 entries: Vec::default(),
403 channel_editing_state: None,
404 selection: None,
405 channel_store: ChannelStore::global(cx),
406 notification_store: NotificationStore::global(cx),
407 current_notification_toast: None,
408 mark_as_read_tasks: HashMap::default(),
409 user_store: workspace.user_store().clone(),
410 project: workspace.project().clone(),
411 subscriptions: Vec::default(),
412 match_candidates: Vec::default(),
413 collapsed_sections: vec![Section::Offline],
414 collapsed_channels: Vec::default(),
415 filter_occupied_channels: false,
416 workspace: workspace.weak_handle(),
417 client: workspace.app_state().client.clone(),
418 };
419
420 this.update_entries(false, cx);
421
422 let active_call = ActiveCall::global(cx);
423 this.subscriptions
424 .push(cx.observe(&this.user_store, |this, _, cx| {
425 this.update_entries(true, cx)
426 }));
427 this.subscriptions
428 .push(cx.observe(&this.channel_store, move |this, _, cx| {
429 this.update_entries(true, cx)
430 }));
431 this.subscriptions
432 .push(cx.observe(&active_call, |this, _, cx| this.update_entries(true, cx)));
433 this.subscriptions.push(cx.subscribe_in(
434 &this.channel_store,
435 window,
436 |this, _channel_store, e, window, cx| match e {
437 ChannelEvent::ChannelCreated(channel_id)
438 | ChannelEvent::ChannelRenamed(channel_id) => {
439 if this.take_editing_state(window, cx) {
440 this.update_entries(false, cx);
441 this.selection = this.entries.iter().position(|entry| {
442 if let ListEntry::Channel { channel, .. } = entry {
443 channel.id == *channel_id
444 } else {
445 false
446 }
447 });
448 }
449 }
450 },
451 ));
452 this.subscriptions.push(cx.subscribe_in(
453 &this.notification_store,
454 window,
455 Self::on_notification_event,
456 ));
457
458 this
459 })
460 }
461
462 pub async fn load(
463 workspace: WeakEntity<Workspace>,
464 mut cx: AsyncWindowContext,
465 ) -> anyhow::Result<Entity<Self>> {
466 let serialized_panel = match workspace
467 .read_with(&cx, |workspace, _| {
468 CollabPanel::serialization_key(workspace)
469 })
470 .ok()
471 .flatten()
472 {
473 Some(serialization_key) => {
474 let kvp = cx.update(|_, cx| KeyValueStore::global(cx))?;
475 kvp.read_kvp(&serialization_key)
476 .context("reading collaboration panel from key value store")
477 .log_err()
478 .flatten()
479 .map(|panel| serde_json::from_str::<SerializedCollabPanel>(&panel))
480 .transpose()
481 .log_err()
482 .flatten()
483 }
484 None => None,
485 };
486
487 workspace.update_in(&mut cx, |workspace, window, cx| {
488 let panel = CollabPanel::new(workspace, window, cx);
489 if let Some(serialized_panel) = serialized_panel {
490 panel.update(cx, |panel, cx| {
491 panel.collapsed_channels = serialized_panel
492 .collapsed_channels
493 .unwrap_or_default()
494 .iter()
495 .map(|cid| ChannelId(*cid))
496 .collect();
497 cx.notify();
498 });
499 }
500
501 let filter_occupied_channels = KeyValueStore::global(cx)
502 .read_kvp(FILTER_OCCUPIED_CHANNELS_KEY)
503 .ok()
504 .flatten()
505 .is_some();
506
507 panel.update(cx, |panel, cx| {
508 panel.filter_occupied_channels = filter_occupied_channels;
509
510 if filter_occupied_channels {
511 panel.update_entries(false, cx);
512 }
513 });
514
515 let favorites: Vec<ChannelId> = KeyValueStore::global(cx)
516 .read_kvp(FAVORITE_CHANNELS_KEY)
517 .ok()
518 .flatten()
519 .and_then(|json| serde_json::from_str::<Vec<u64>>(&json).ok())
520 .unwrap_or_default()
521 .into_iter()
522 .map(ChannelId)
523 .collect();
524
525 if !favorites.is_empty() {
526 panel.update(cx, |panel, cx| {
527 panel.channel_store.update(cx, |store, cx| {
528 store.set_favorite_channel_ids(favorites, cx);
529 });
530 });
531 }
532
533 panel
534 })
535 }
536
537 fn serialization_key(workspace: &Workspace) -> Option<String> {
538 workspace
539 .database_id()
540 .map(|id| i64::from(id).to_string())
541 .or(workspace.session_id())
542 .map(|id| format!("{}-{:?}", COLLABORATION_PANEL_KEY, id))
543 }
544
545 fn serialize(&mut self, cx: &mut Context<Self>) {
546 let Some(serialization_key) = self
547 .workspace
548 .read_with(cx, |workspace, _| CollabPanel::serialization_key(workspace))
549 .ok()
550 .flatten()
551 else {
552 return;
553 };
554 let collapsed_channels = if self.collapsed_channels.is_empty() {
555 None
556 } else {
557 Some(self.collapsed_channels.iter().map(|id| id.0).collect())
558 };
559
560 let kvp = KeyValueStore::global(cx);
561 self.pending_panel_serialization = cx.background_spawn(
562 async move {
563 kvp.write_kvp(
564 serialization_key,
565 serde_json::to_string(&SerializedCollabPanel { collapsed_channels })?,
566 )
567 .await?;
568 anyhow::Ok(())
569 }
570 .log_err(),
571 );
572 }
573
574 fn scroll_to_item(&mut self, ix: usize) {
575 self.list_state.scroll_to_reveal_item(ix)
576 }
577
578 fn update_entries(&mut self, select_same_item: bool, cx: &mut Context<Self>) {
579 let query = self.filter_editor.read(cx).text(cx);
580 let fg_executor = cx.foreground_executor().clone();
581 let executor = cx.background_executor().clone();
582
583 let prev_selected_entry = self.selection.and_then(|ix| self.entries.get(ix).cloned());
584 let old_entries = mem::take(&mut self.entries);
585 let mut scroll_to_top = false;
586
587 if let Some(room) = ActiveCall::global(cx).read(cx).room() {
588 self.entries.push(ListEntry::Header(Section::ActiveCall));
589 if !old_entries
590 .iter()
591 .any(|entry| matches!(entry, ListEntry::Header(Section::ActiveCall)))
592 {
593 scroll_to_top = true;
594 }
595
596 if !self.collapsed_sections.contains(&Section::ActiveCall) {
597 let room = room.read(cx);
598
599 if query.is_empty()
600 && let Some(channel_id) = room.channel_id()
601 {
602 self.entries.push(ListEntry::ChannelNotes { channel_id });
603 }
604
605 // Populate the active user.
606 if let Some(user) = self.user_store.read(cx).current_user() {
607 self.match_candidates.clear();
608 self.match_candidates
609 .push(StringMatchCandidate::new(0, &user.github_login));
610 let matches = fg_executor.block_on(match_strings(
611 &self.match_candidates,
612 &query,
613 true,
614 true,
615 usize::MAX,
616 &Default::default(),
617 executor.clone(),
618 ));
619 if !matches.is_empty() {
620 let user_id = user.id;
621 self.entries.push(ListEntry::CallParticipant {
622 user,
623 peer_id: None,
624 is_pending: false,
625 role: room.local_participant().role,
626 });
627 let mut projects = room.local_participant().projects.iter().peekable();
628 while let Some(project) = projects.next() {
629 self.entries.push(ListEntry::ParticipantProject {
630 project_id: project.id,
631 worktree_root_names: project.worktree_root_names.clone(),
632 host_user_id: user_id,
633 is_last: projects.peek().is_none() && !room.is_sharing_screen(),
634 });
635 }
636 if room.is_sharing_screen() {
637 self.entries.push(ListEntry::ParticipantScreen {
638 peer_id: None,
639 is_last: true,
640 });
641 }
642 }
643 }
644
645 // Populate remote participants.
646 self.match_candidates.clear();
647 self.match_candidates
648 .extend(room.remote_participants().values().map(|participant| {
649 StringMatchCandidate::new(
650 participant.user.id as usize,
651 &participant.user.github_login,
652 )
653 }));
654 let mut matches = fg_executor.block_on(match_strings(
655 &self.match_candidates,
656 &query,
657 true,
658 true,
659 usize::MAX,
660 &Default::default(),
661 executor.clone(),
662 ));
663 matches.sort_by(|a, b| {
664 let a_is_guest = room.role_for_user(a.candidate_id as u64)
665 == Some(proto::ChannelRole::Guest);
666 let b_is_guest = room.role_for_user(b.candidate_id as u64)
667 == Some(proto::ChannelRole::Guest);
668 a_is_guest
669 .cmp(&b_is_guest)
670 .then_with(|| a.string.cmp(&b.string))
671 });
672 for mat in matches {
673 let user_id = mat.candidate_id as u64;
674 let participant = &room.remote_participants()[&user_id];
675 self.entries.push(ListEntry::CallParticipant {
676 user: participant.user.clone(),
677 peer_id: Some(participant.peer_id),
678 is_pending: false,
679 role: participant.role,
680 });
681 let mut projects = participant.projects.iter().peekable();
682 while let Some(project) = projects.next() {
683 self.entries.push(ListEntry::ParticipantProject {
684 project_id: project.id,
685 worktree_root_names: project.worktree_root_names.clone(),
686 host_user_id: participant.user.id,
687 is_last: projects.peek().is_none() && !participant.has_video_tracks(),
688 });
689 }
690 if participant.has_video_tracks() {
691 self.entries.push(ListEntry::ParticipantScreen {
692 peer_id: Some(participant.peer_id),
693 is_last: true,
694 });
695 }
696 }
697
698 // Populate pending participants.
699 self.match_candidates.clear();
700 self.match_candidates
701 .extend(room.pending_participants().iter().enumerate().map(
702 |(id, participant)| {
703 StringMatchCandidate::new(id, &participant.github_login)
704 },
705 ));
706 let matches = fg_executor.block_on(match_strings(
707 &self.match_candidates,
708 &query,
709 true,
710 true,
711 usize::MAX,
712 &Default::default(),
713 executor.clone(),
714 ));
715 self.entries
716 .extend(matches.iter().map(|mat| ListEntry::CallParticipant {
717 user: room.pending_participants()[mat.candidate_id].clone(),
718 peer_id: None,
719 is_pending: true,
720 role: proto::ChannelRole::Member,
721 }));
722 }
723 }
724
725 let mut request_entries = Vec::new();
726
727 let channel_store = self.channel_store.read(cx);
728 let user_store = self.user_store.read(cx);
729
730 let favorite_ids = channel_store.favorite_channel_ids();
731 if !favorite_ids.is_empty() {
732 let favorite_channels: Vec<_> = favorite_ids
733 .iter()
734 .filter_map(|id| channel_store.channel_for_id(*id))
735 .collect();
736
737 self.match_candidates.clear();
738 self.match_candidates.extend(
739 favorite_channels
740 .iter()
741 .enumerate()
742 .map(|(ix, channel)| StringMatchCandidate::new(ix, &channel.name)),
743 );
744
745 let matches = fg_executor.block_on(match_strings(
746 &self.match_candidates,
747 &query,
748 true,
749 true,
750 usize::MAX,
751 &Default::default(),
752 executor.clone(),
753 ));
754
755 if !matches.is_empty() || query.is_empty() {
756 self.entries
757 .push(ListEntry::Header(Section::FavoriteChannels));
758
759 let matches_by_candidate: HashMap<usize, &StringMatch> =
760 matches.iter().map(|mat| (mat.candidate_id, mat)).collect();
761
762 for (ix, channel) in favorite_channels.iter().enumerate() {
763 if !query.is_empty() && !matches_by_candidate.contains_key(&ix) {
764 continue;
765 }
766 self.entries.push(ListEntry::Channel {
767 channel: (*channel).clone(),
768 depth: 0,
769 has_children: false,
770 is_favorite: true,
771 string_match: matches_by_candidate.get(&ix).cloned().cloned(),
772 });
773 }
774 }
775 }
776
777 self.entries.push(ListEntry::Header(Section::Channels));
778
779 if channel_store.channel_count() > 0 || self.channel_editing_state.is_some() {
780 self.match_candidates.clear();
781 self.match_candidates.extend(
782 channel_store
783 .ordered_channels()
784 .enumerate()
785 .map(|(ix, (_, channel))| StringMatchCandidate::new(ix, &channel.name)),
786 );
787 let mut channels = channel_store
788 .ordered_channels()
789 .map(|(_, chan)| chan)
790 .collect::<Vec<_>>();
791 let matches = fg_executor.block_on(match_strings(
792 &self.match_candidates,
793 &query,
794 true,
795 true,
796 usize::MAX,
797 &Default::default(),
798 executor.clone(),
799 ));
800
801 let matches_by_id: HashMap<_, _> = matches
802 .iter()
803 .map(|mat| (channels[mat.candidate_id].id, mat.clone()))
804 .collect();
805
806 let channel_ids_of_matches_or_parents: HashSet<_> = matches
807 .iter()
808 .flat_map(|mat| {
809 let match_channel = channels[mat.candidate_id];
810
811 match_channel
812 .parent_path
813 .iter()
814 .copied()
815 .chain(Some(match_channel.id))
816 })
817 .collect();
818
819 channels.retain(|chan| channel_ids_of_matches_or_parents.contains(&chan.id));
820
821 if self.filter_occupied_channels {
822 let occupied_channel_ids_or_ancestors: HashSet<_> = channel_store
823 .ordered_channels()
824 .map(|(_, channel)| channel)
825 .filter(|channel| !channel_store.channel_participants(channel.id).is_empty())
826 .flat_map(|channel| channel.parent_path.iter().copied().chain(Some(channel.id)))
827 .collect();
828 channels.retain(|channel| occupied_channel_ids_or_ancestors.contains(&channel.id));
829 }
830
831 if let Some(state) = &self.channel_editing_state
832 && matches!(state, ChannelEditingState::Create { location: None, .. })
833 {
834 self.entries.push(ListEntry::ChannelEditor { depth: 0 });
835 }
836
837 let should_respect_collapse = query.is_empty() && !self.filter_occupied_channels;
838 let mut collapse_depth = None;
839
840 for (idx, channel) in channels.into_iter().enumerate() {
841 let depth = channel.parent_path.len();
842
843 if should_respect_collapse {
844 if collapse_depth.is_none() && self.is_channel_collapsed(channel.id) {
845 collapse_depth = Some(depth);
846 } else if let Some(collapsed_depth) = collapse_depth {
847 if depth > collapsed_depth {
848 continue;
849 }
850 if self.is_channel_collapsed(channel.id) {
851 collapse_depth = Some(depth);
852 } else {
853 collapse_depth = None;
854 }
855 }
856 }
857
858 let has_children = channel_store
859 .channel_at_index(idx + 1)
860 .is_some_and(|next_channel| next_channel.parent_path.ends_with(&[channel.id]));
861
862 match &self.channel_editing_state {
863 Some(ChannelEditingState::Create {
864 location: parent_id,
865 ..
866 }) if *parent_id == Some(channel.id) => {
867 self.entries.push(ListEntry::Channel {
868 channel: channel.clone(),
869 depth,
870 has_children: false,
871 is_favorite: false,
872 string_match: matches_by_id.get(&channel.id).map(|mat| (*mat).clone()),
873 });
874 self.entries
875 .push(ListEntry::ChannelEditor { depth: depth + 1 });
876 }
877 Some(ChannelEditingState::Rename {
878 location: parent_id,
879 ..
880 }) if parent_id == &channel.id => {
881 self.entries.push(ListEntry::ChannelEditor { depth });
882 }
883 _ => {
884 self.entries.push(ListEntry::Channel {
885 channel: channel.clone(),
886 depth,
887 has_children,
888 is_favorite: false,
889 string_match: matches_by_id.get(&channel.id).map(|mat| (*mat).clone()),
890 });
891 }
892 }
893 }
894 }
895
896 let channel_invites = channel_store.channel_invitations();
897 if !channel_invites.is_empty() {
898 self.match_candidates.clear();
899 self.match_candidates.extend(
900 channel_invites
901 .iter()
902 .enumerate()
903 .map(|(ix, channel)| StringMatchCandidate::new(ix, &channel.name)),
904 );
905 let matches = fg_executor.block_on(match_strings(
906 &self.match_candidates,
907 &query,
908 true,
909 true,
910 usize::MAX,
911 &Default::default(),
912 executor.clone(),
913 ));
914 request_entries.extend(
915 matches
916 .iter()
917 .map(|mat| ListEntry::ChannelInvite(channel_invites[mat.candidate_id].clone())),
918 );
919
920 if !request_entries.is_empty() {
921 self.entries
922 .push(ListEntry::Header(Section::ChannelInvites));
923 if !self.collapsed_sections.contains(&Section::ChannelInvites) {
924 self.entries.append(&mut request_entries);
925 }
926 }
927 }
928
929 self.entries.push(ListEntry::Header(Section::Contacts));
930
931 request_entries.clear();
932 let incoming = user_store.incoming_contact_requests();
933 if !incoming.is_empty() {
934 self.match_candidates.clear();
935 self.match_candidates.extend(
936 incoming
937 .iter()
938 .enumerate()
939 .map(|(ix, user)| StringMatchCandidate::new(ix, &user.github_login)),
940 );
941 let matches = fg_executor.block_on(match_strings(
942 &self.match_candidates,
943 &query,
944 true,
945 true,
946 usize::MAX,
947 &Default::default(),
948 executor.clone(),
949 ));
950 request_entries.extend(
951 matches
952 .iter()
953 .map(|mat| ListEntry::IncomingRequest(incoming[mat.candidate_id].clone())),
954 );
955 }
956
957 let outgoing = user_store.outgoing_contact_requests();
958 if !outgoing.is_empty() {
959 self.match_candidates.clear();
960 self.match_candidates.extend(
961 outgoing
962 .iter()
963 .enumerate()
964 .map(|(ix, user)| StringMatchCandidate::new(ix, &user.github_login)),
965 );
966 let matches = fg_executor.block_on(match_strings(
967 &self.match_candidates,
968 &query,
969 true,
970 true,
971 usize::MAX,
972 &Default::default(),
973 executor.clone(),
974 ));
975 request_entries.extend(
976 matches
977 .iter()
978 .map(|mat| ListEntry::OutgoingRequest(outgoing[mat.candidate_id].clone())),
979 );
980 }
981
982 if !request_entries.is_empty() {
983 self.entries
984 .push(ListEntry::Header(Section::ContactRequests));
985 if !self.collapsed_sections.contains(&Section::ContactRequests) {
986 self.entries.append(&mut request_entries);
987 }
988 }
989
990 let contacts = user_store.contacts();
991 if !contacts.is_empty() {
992 self.match_candidates.clear();
993 self.match_candidates.extend(
994 contacts
995 .iter()
996 .enumerate()
997 .map(|(ix, contact)| StringMatchCandidate::new(ix, &contact.user.github_login)),
998 );
999
1000 let matches = fg_executor.block_on(match_strings(
1001 &self.match_candidates,
1002 &query,
1003 true,
1004 true,
1005 usize::MAX,
1006 &Default::default(),
1007 executor,
1008 ));
1009
1010 let (online_contacts, offline_contacts) = matches
1011 .iter()
1012 .partition::<Vec<_>, _>(|mat| contacts[mat.candidate_id].online);
1013
1014 for (matches, section) in [
1015 (online_contacts, Section::Online),
1016 (offline_contacts, Section::Offline),
1017 ] {
1018 if !matches.is_empty() {
1019 self.entries.push(ListEntry::Header(section));
1020 if !self.collapsed_sections.contains(§ion) {
1021 let active_call = &ActiveCall::global(cx).read(cx);
1022 for mat in matches {
1023 let contact = &contacts[mat.candidate_id];
1024 self.entries.push(ListEntry::Contact {
1025 contact: contact.clone(),
1026 calling: active_call.pending_invites().contains(&contact.user.id),
1027 });
1028 }
1029 }
1030 }
1031 }
1032 }
1033
1034 if incoming.is_empty() && outgoing.is_empty() && contacts.is_empty() {
1035 self.entries.push(ListEntry::ContactPlaceholder);
1036 }
1037
1038 if select_same_item {
1039 if let Some(prev_selected_entry) = prev_selected_entry {
1040 let prev_selection = self.selection.take();
1041 for (ix, entry) in self.entries.iter().enumerate() {
1042 if *entry == prev_selected_entry {
1043 self.selection = Some(ix);
1044 break;
1045 }
1046 }
1047 if self.selection.is_none() {
1048 self.selection = prev_selection.and_then(|prev_ix| {
1049 if self.entries.is_empty() {
1050 None
1051 } else {
1052 Some(prev_ix.min(self.entries.len() - 1))
1053 }
1054 });
1055 }
1056 }
1057 } else {
1058 self.selection = self.selection.and_then(|prev_selection| {
1059 if self.entries.is_empty() {
1060 None
1061 } else {
1062 Some(prev_selection.min(self.entries.len() - 1))
1063 }
1064 });
1065 }
1066
1067 let old_scroll_top = self.list_state.logical_scroll_top();
1068 self.list_state.reset(self.entries.len());
1069
1070 if scroll_to_top {
1071 self.list_state.scroll_to(ListOffset::default());
1072 } else {
1073 // Attempt to maintain the same scroll position.
1074 if let Some(old_top_entry) = old_entries.get(old_scroll_top.item_ix) {
1075 let new_scroll_top = self
1076 .entries
1077 .iter()
1078 .position(|entry| entry == old_top_entry)
1079 .map(|item_ix| ListOffset {
1080 item_ix,
1081 offset_in_item: old_scroll_top.offset_in_item,
1082 })
1083 .or_else(|| {
1084 let entry_after_old_top = old_entries.get(old_scroll_top.item_ix + 1)?;
1085 let item_ix = self
1086 .entries
1087 .iter()
1088 .position(|entry| entry == entry_after_old_top)?;
1089 Some(ListOffset {
1090 item_ix,
1091 offset_in_item: Pixels::ZERO,
1092 })
1093 })
1094 .or_else(|| {
1095 let entry_before_old_top =
1096 old_entries.get(old_scroll_top.item_ix.saturating_sub(1))?;
1097 let item_ix = self
1098 .entries
1099 .iter()
1100 .position(|entry| entry == entry_before_old_top)?;
1101 Some(ListOffset {
1102 item_ix,
1103 offset_in_item: Pixels::ZERO,
1104 })
1105 });
1106
1107 self.list_state
1108 .scroll_to(new_scroll_top.unwrap_or(old_scroll_top));
1109 }
1110 }
1111
1112 cx.notify();
1113 }
1114
1115 fn render_call_participant(
1116 &self,
1117 user: &Arc<User>,
1118 peer_id: Option<PeerId>,
1119 is_pending: bool,
1120 role: proto::ChannelRole,
1121 is_selected: bool,
1122 cx: &mut Context<Self>,
1123 ) -> ListItem {
1124 let user_id = user.id;
1125 let is_current_user =
1126 self.user_store.read(cx).current_user().map(|user| user.id) == Some(user_id);
1127 let tooltip = format!("Follow {}", user.github_login);
1128
1129 let is_call_admin = ActiveCall::global(cx).read(cx).room().is_some_and(|room| {
1130 room.read(cx).local_participant().role == proto::ChannelRole::Admin
1131 });
1132
1133 let end_slot = if is_pending {
1134 Label::new("Calling").color(Color::Muted).into_any_element()
1135 } else if is_current_user {
1136 IconButton::new("leave-call", IconName::Exit)
1137 .icon_size(IconSize::Small)
1138 .tooltip(Tooltip::text("Leave Call"))
1139 .on_click(move |_, window, cx| Self::leave_call(window, cx))
1140 .into_any_element()
1141 } else if role == proto::ChannelRole::Guest {
1142 Label::new("Guest").color(Color::Muted).into_any_element()
1143 } else if role == proto::ChannelRole::Talker {
1144 Label::new("Mic only")
1145 .color(Color::Muted)
1146 .into_any_element()
1147 } else {
1148 Empty.into_any_element()
1149 };
1150
1151 ListItem::new(user.github_login.clone())
1152 .start_slot(Avatar::new(user.avatar_uri.clone()))
1153 .child(render_participant_name_and_handle(user))
1154 .toggle_state(is_selected)
1155 .end_slot(end_slot)
1156 .tooltip(Tooltip::text("Click to Follow"))
1157 .when_some(peer_id, |el, peer_id| {
1158 if role == proto::ChannelRole::Guest {
1159 return el;
1160 }
1161 el.tooltip(Tooltip::text(tooltip.clone()))
1162 .on_click(cx.listener(move |this, _, window, cx| {
1163 this.workspace
1164 .update(cx, |workspace, cx| workspace.follow(peer_id, window, cx))
1165 .ok();
1166 }))
1167 })
1168 .when(is_call_admin, |el| {
1169 el.on_secondary_mouse_down(cx.listener(
1170 move |this, event: &MouseDownEvent, window, cx| {
1171 this.deploy_participant_context_menu(
1172 event.position,
1173 user_id,
1174 role,
1175 window,
1176 cx,
1177 )
1178 },
1179 ))
1180 })
1181 }
1182
1183 fn render_participant_project(
1184 &self,
1185 project_id: u64,
1186 worktree_root_names: &[String],
1187 host_user_id: u64,
1188 is_last: bool,
1189 is_selected: bool,
1190 window: &mut Window,
1191 cx: &mut Context<Self>,
1192 ) -> impl IntoElement {
1193 let project_name: SharedString = if worktree_root_names.is_empty() {
1194 "untitled".to_string()
1195 } else {
1196 worktree_root_names.join(", ")
1197 }
1198 .into();
1199
1200 ListItem::new(project_id as usize)
1201 .height(rems_from_px(24.))
1202 .toggle_state(is_selected)
1203 .on_click(cx.listener(move |this, _, window, cx| {
1204 this.workspace
1205 .update(cx, |workspace, cx| {
1206 let app_state = workspace.app_state().clone();
1207 workspace::join_in_room_project(project_id, host_user_id, app_state, cx)
1208 .detach_and_prompt_err(
1209 "Failed to join project",
1210 window,
1211 cx,
1212 |error, _, _| Some(format!("{error:#}")),
1213 );
1214 })
1215 .ok();
1216 }))
1217 .start_slot(
1218 h_flex()
1219 .gap_1p5()
1220 .child(render_tree_branch(is_last, false, window, cx))
1221 .child(
1222 Icon::new(IconName::Folder)
1223 .size(IconSize::Small)
1224 .color(Color::Muted),
1225 ),
1226 )
1227 .child(Label::new(project_name.clone()))
1228 .tooltip(Tooltip::text(format!("Open {}", project_name)))
1229 }
1230
1231 fn render_participant_screen(
1232 &self,
1233 peer_id: Option<PeerId>,
1234 is_last: bool,
1235 is_selected: bool,
1236 window: &mut Window,
1237 cx: &mut Context<Self>,
1238 ) -> impl IntoElement {
1239 let id = peer_id.map_or(usize::MAX, |id| id.as_u64() as usize);
1240
1241 ListItem::new(("screen", id))
1242 .height(rems_from_px(24.))
1243 .toggle_state(is_selected)
1244 .start_slot(
1245 h_flex()
1246 .gap_1p5()
1247 .child(render_tree_branch(is_last, false, window, cx))
1248 .child(
1249 Icon::new(IconName::Screen)
1250 .size(IconSize::Small)
1251 .color(Color::Muted),
1252 ),
1253 )
1254 .child(Label::new("Screen"))
1255 .when_some(peer_id, |this, _| {
1256 this.on_click(cx.listener(move |this, _, window, cx| {
1257 this.workspace
1258 .update(cx, |workspace, cx| {
1259 workspace.open_shared_screen(peer_id.unwrap(), window, cx)
1260 })
1261 .ok();
1262 }))
1263 .tooltip(Tooltip::text("Open Shared Screen"))
1264 })
1265 }
1266
1267 fn take_editing_state(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
1268 if self.channel_editing_state.take().is_some() {
1269 self.channel_name_editor.update(cx, |editor, cx| {
1270 editor.set_text("", window, cx);
1271 });
1272 true
1273 } else {
1274 false
1275 }
1276 }
1277
1278 fn render_channel_notes(
1279 &self,
1280 channel_id: ChannelId,
1281 is_selected: bool,
1282 window: &mut Window,
1283 cx: &mut Context<Self>,
1284 ) -> impl IntoElement {
1285 let channel_store = self.channel_store.read(cx);
1286 let has_channel_buffer_changed = channel_store.has_channel_buffer_changed(channel_id);
1287
1288 ListItem::new("channel-notes")
1289 .height(rems_from_px(24.))
1290 .toggle_state(is_selected)
1291 .on_click(cx.listener(move |this, _, window, cx| {
1292 this.open_channel_notes(channel_id, window, cx);
1293 }))
1294 .start_slot(
1295 h_flex()
1296 .relative()
1297 .gap_1p5()
1298 .child(render_tree_branch(false, true, window, cx))
1299 .child(
1300 h_flex()
1301 .child(
1302 Icon::new(IconName::Reader)
1303 .size(IconSize::Small)
1304 .color(Color::Muted),
1305 )
1306 .when(has_channel_buffer_changed, |this| {
1307 this.child(
1308 div()
1309 .absolute()
1310 .top_neg_0p5()
1311 .right_0()
1312 .child(Indicator::dot().color(Color::Info)),
1313 )
1314 }),
1315 ),
1316 )
1317 .child(Label::new("notes"))
1318 .tooltip(Tooltip::text("Open Channel Notes"))
1319 }
1320
1321 fn has_subchannels(&self, ix: usize) -> bool {
1322 self.entries.get(ix).is_some_and(|entry| {
1323 if let ListEntry::Channel { has_children, .. } = entry {
1324 *has_children
1325 } else {
1326 false
1327 }
1328 })
1329 }
1330
1331 fn deploy_participant_context_menu(
1332 &mut self,
1333 position: Point<Pixels>,
1334 user_id: u64,
1335 role: proto::ChannelRole,
1336 window: &mut Window,
1337 cx: &mut Context<Self>,
1338 ) {
1339 let this = cx.entity();
1340 if !(role == proto::ChannelRole::Guest
1341 || role == proto::ChannelRole::Talker
1342 || role == proto::ChannelRole::Member)
1343 {
1344 return;
1345 }
1346
1347 let context_menu = ContextMenu::build(window, cx, |mut context_menu, window, _| {
1348 if role == proto::ChannelRole::Guest {
1349 context_menu = context_menu.entry(
1350 "Grant Mic Access",
1351 None,
1352 window.handler_for(&this, move |_, window, cx| {
1353 ActiveCall::global(cx)
1354 .update(cx, |call, cx| {
1355 let Some(room) = call.room() else {
1356 return Task::ready(Ok(()));
1357 };
1358 room.update(cx, |room, cx| {
1359 room.set_participant_role(
1360 user_id,
1361 proto::ChannelRole::Talker,
1362 cx,
1363 )
1364 })
1365 })
1366 .detach_and_prompt_err(
1367 "Failed to grant mic access",
1368 window,
1369 cx,
1370 |_, _, _| None,
1371 )
1372 }),
1373 );
1374 }
1375 if role == proto::ChannelRole::Guest || role == proto::ChannelRole::Talker {
1376 context_menu = context_menu.entry(
1377 "Grant Write Access",
1378 None,
1379 window.handler_for(&this, move |_, window, cx| {
1380 ActiveCall::global(cx)
1381 .update(cx, |call, cx| {
1382 let Some(room) = call.room() else {
1383 return Task::ready(Ok(()));
1384 };
1385 room.update(cx, |room, cx| {
1386 room.set_participant_role(
1387 user_id,
1388 proto::ChannelRole::Member,
1389 cx,
1390 )
1391 })
1392 })
1393 .detach_and_prompt_err("Failed to grant write access", window, cx, |e, _, _| {
1394 match e.error_code() {
1395 ErrorCode::NeedsCla => Some("This user has not yet signed the CLA at https://zed.dev/cla.".into()),
1396 _ => None,
1397 }
1398 })
1399 }),
1400 );
1401 }
1402 if role == proto::ChannelRole::Member || role == proto::ChannelRole::Talker {
1403 let label = if role == proto::ChannelRole::Talker {
1404 "Mute"
1405 } else {
1406 "Revoke Access"
1407 };
1408 context_menu = context_menu.entry(
1409 label,
1410 None,
1411 window.handler_for(&this, move |_, window, cx| {
1412 ActiveCall::global(cx)
1413 .update(cx, |call, cx| {
1414 let Some(room) = call.room() else {
1415 return Task::ready(Ok(()));
1416 };
1417 room.update(cx, |room, cx| {
1418 room.set_participant_role(
1419 user_id,
1420 proto::ChannelRole::Guest,
1421 cx,
1422 )
1423 })
1424 })
1425 .detach_and_prompt_err(
1426 "Failed to revoke access",
1427 window,
1428 cx,
1429 |_, _, _| None,
1430 )
1431 }),
1432 );
1433 }
1434
1435 context_menu
1436 });
1437
1438 window.focus(&context_menu.focus_handle(cx), cx);
1439 let subscription = cx.subscribe_in(
1440 &context_menu,
1441 window,
1442 |this, _, _: &DismissEvent, window, cx| {
1443 if this.context_menu.as_ref().is_some_and(|context_menu| {
1444 context_menu.0.focus_handle(cx).contains_focused(window, cx)
1445 }) {
1446 cx.focus_self(window);
1447 }
1448 this.context_menu.take();
1449 cx.notify();
1450 },
1451 );
1452 self.context_menu = Some((context_menu, position, subscription));
1453 }
1454
1455 fn deploy_channel_context_menu(
1456 &mut self,
1457 position: Point<Pixels>,
1458 channel_id: ChannelId,
1459 ix: usize,
1460 window: &mut Window,
1461 cx: &mut Context<Self>,
1462 ) {
1463 let clipboard_channel_name = self.channel_clipboard.as_ref().and_then(|clipboard| {
1464 self.channel_store
1465 .read(cx)
1466 .channel_for_id(clipboard.channel_id)
1467 .map(|channel| channel.name.clone())
1468 });
1469 let this = cx.entity();
1470
1471 let context_menu = ContextMenu::build(window, cx, |mut context_menu, window, cx| {
1472 if self.has_subchannels(ix) {
1473 let expand_action_name = if self.is_channel_collapsed(channel_id) {
1474 "Expand Subchannels"
1475 } else {
1476 "Collapse Subchannels"
1477 };
1478 context_menu = context_menu.entry(
1479 expand_action_name,
1480 None,
1481 window.handler_for(&this, move |this, window, cx| {
1482 this.toggle_channel_collapsed(channel_id, window, cx)
1483 }),
1484 );
1485 }
1486
1487 context_menu = context_menu
1488 .entry(
1489 "Open Notes",
1490 None,
1491 window.handler_for(&this, move |this, window, cx| {
1492 this.open_channel_notes(channel_id, window, cx)
1493 }),
1494 )
1495 .entry(
1496 "Copy Channel Link",
1497 None,
1498 window.handler_for(&this, move |this, _, cx| {
1499 this.copy_channel_link(channel_id, cx)
1500 }),
1501 )
1502 .entry(
1503 "Copy Channel Notes Link",
1504 None,
1505 window.handler_for(&this, move |this, _, cx| {
1506 this.copy_channel_notes_link(channel_id, cx)
1507 }),
1508 )
1509 .separator()
1510 .entry(
1511 if self.is_channel_favorited(channel_id, cx) {
1512 "Remove from Favorites"
1513 } else {
1514 "Add to Favorites"
1515 },
1516 None,
1517 window.handler_for(&this, move |this, _window, cx| {
1518 this.toggle_favorite_channel(channel_id, cx)
1519 }),
1520 );
1521
1522 let mut has_destructive_actions = false;
1523 if self.channel_store.read(cx).is_channel_admin(channel_id) {
1524 has_destructive_actions = true;
1525 context_menu = context_menu
1526 .separator()
1527 .entry(
1528 "New Subchannel",
1529 None,
1530 window.handler_for(&this, move |this, window, cx| {
1531 this.new_subchannel(channel_id, window, cx)
1532 }),
1533 )
1534 .entry(
1535 "Rename",
1536 Some(Box::new(SecondaryConfirm)),
1537 window.handler_for(&this, move |this, window, cx| {
1538 this.rename_channel(channel_id, window, cx)
1539 }),
1540 );
1541
1542 if let Some(channel_name) = clipboard_channel_name {
1543 context_menu = context_menu.separator().entry(
1544 format!("Move '#{}' here", channel_name),
1545 None,
1546 window.handler_for(&this, move |this, window, cx| {
1547 this.move_channel_on_clipboard(channel_id, window, cx)
1548 }),
1549 );
1550 }
1551
1552 if self.channel_store.read(cx).is_root_channel(channel_id) {
1553 context_menu = context_menu.separator().entry(
1554 "Manage Members",
1555 None,
1556 window.handler_for(&this, move |this, window, cx| {
1557 this.manage_members(channel_id, window, cx)
1558 }),
1559 )
1560 } else {
1561 context_menu = context_menu.entry(
1562 "Move this channel",
1563 None,
1564 window.handler_for(&this, move |this, window, cx| {
1565 this.start_move_channel(channel_id, window, cx)
1566 }),
1567 );
1568 if self.channel_store.read(cx).is_public_channel(channel_id) {
1569 context_menu = context_menu.separator().entry(
1570 "Make Channel Private",
1571 None,
1572 window.handler_for(&this, move |this, window, cx| {
1573 this.set_channel_visibility(
1574 channel_id,
1575 ChannelVisibility::Members,
1576 window,
1577 cx,
1578 )
1579 }),
1580 )
1581 } else {
1582 context_menu = context_menu.separator().entry(
1583 "Make Channel Public",
1584 None,
1585 window.handler_for(&this, move |this, window, cx| {
1586 this.set_channel_visibility(
1587 channel_id,
1588 ChannelVisibility::Public,
1589 window,
1590 cx,
1591 )
1592 }),
1593 )
1594 }
1595 }
1596
1597 context_menu = context_menu.entry(
1598 "Delete",
1599 None,
1600 window.handler_for(&this, move |this, window, cx| {
1601 this.remove_channel(channel_id, window, cx)
1602 }),
1603 );
1604 }
1605
1606 if self.channel_store.read(cx).is_root_channel(channel_id) {
1607 if !has_destructive_actions {
1608 context_menu = context_menu.separator()
1609 }
1610 context_menu = context_menu.entry(
1611 "Leave Channel",
1612 None,
1613 window.handler_for(&this, move |this, window, cx| {
1614 this.leave_channel(channel_id, window, cx)
1615 }),
1616 );
1617 }
1618
1619 context_menu
1620 });
1621
1622 window.focus(&context_menu.focus_handle(cx), cx);
1623 let subscription = cx.subscribe_in(
1624 &context_menu,
1625 window,
1626 |this, _, _: &DismissEvent, window, cx| {
1627 if this.context_menu.as_ref().is_some_and(|context_menu| {
1628 context_menu.0.focus_handle(cx).contains_focused(window, cx)
1629 }) {
1630 cx.focus_self(window);
1631 }
1632 this.context_menu.take();
1633 cx.notify();
1634 },
1635 );
1636 self.context_menu = Some((context_menu, position, subscription));
1637
1638 cx.notify();
1639 }
1640
1641 fn deploy_contact_context_menu(
1642 &mut self,
1643 position: Point<Pixels>,
1644 contact: Arc<Contact>,
1645 window: &mut Window,
1646 cx: &mut Context<Self>,
1647 ) {
1648 let this = cx.entity();
1649 let in_room = ActiveCall::global(cx).read(cx).room().is_some();
1650
1651 let context_menu = ContextMenu::build(window, cx, |mut context_menu, _, _| {
1652 let user_id = contact.user.id;
1653
1654 if contact.online && !contact.busy {
1655 let label = if in_room {
1656 format!("Invite {} to join", contact.user.github_login)
1657 } else {
1658 format!("Call {}", contact.user.github_login)
1659 };
1660 context_menu = context_menu.entry(label, None, {
1661 let this = this.clone();
1662 move |window, cx| {
1663 this.update(cx, |this, cx| {
1664 this.call(user_id, window, cx);
1665 });
1666 }
1667 });
1668 }
1669
1670 context_menu.entry("Remove Contact", None, {
1671 let this = this.clone();
1672 move |window, cx| {
1673 this.update(cx, |this, cx| {
1674 this.remove_contact(
1675 contact.user.id,
1676 &contact.user.github_login,
1677 window,
1678 cx,
1679 );
1680 });
1681 }
1682 })
1683 });
1684
1685 window.focus(&context_menu.focus_handle(cx), cx);
1686 let subscription = cx.subscribe_in(
1687 &context_menu,
1688 window,
1689 |this, _, _: &DismissEvent, window, cx| {
1690 if this.context_menu.as_ref().is_some_and(|context_menu| {
1691 context_menu.0.focus_handle(cx).contains_focused(window, cx)
1692 }) {
1693 cx.focus_self(window);
1694 }
1695 this.context_menu.take();
1696 cx.notify();
1697 },
1698 );
1699 self.context_menu = Some((context_menu, position, subscription));
1700
1701 cx.notify();
1702 }
1703
1704 fn reset_filter_editor_text(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
1705 self.filter_editor.update(cx, |editor, cx| {
1706 if editor.buffer().read(cx).len(cx).0 > 0 {
1707 editor.set_text("", window, cx);
1708 true
1709 } else {
1710 false
1711 }
1712 })
1713 }
1714
1715 fn cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context<Self>) {
1716 if cx.stop_active_drag(window) {
1717 return;
1718 } else if self.take_editing_state(window, cx) {
1719 window.focus(&self.filter_editor.focus_handle(cx), cx);
1720 } else if !self.reset_filter_editor_text(window, cx) {
1721 self.focus_handle.focus(window, cx);
1722 }
1723
1724 if self.context_menu.is_some() {
1725 self.context_menu.take();
1726 cx.notify();
1727 }
1728
1729 self.update_entries(false, cx);
1730 }
1731
1732 pub fn select_next(&mut self, _: &SelectNext, _: &mut Window, cx: &mut Context<Self>) {
1733 let ix = self.selection.map_or(0, |ix| ix + 1);
1734 if ix < self.entries.len() {
1735 self.selection = Some(ix);
1736 }
1737
1738 if let Some(ix) = self.selection {
1739 self.scroll_to_item(ix)
1740 }
1741 cx.notify();
1742 }
1743
1744 pub fn select_previous(&mut self, _: &SelectPrevious, _: &mut Window, cx: &mut Context<Self>) {
1745 let ix = self.selection.take().unwrap_or(0);
1746 if ix > 0 {
1747 self.selection = Some(ix - 1);
1748 }
1749
1750 if let Some(ix) = self.selection {
1751 self.scroll_to_item(ix)
1752 }
1753 cx.notify();
1754 }
1755
1756 fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
1757 if self.confirm_channel_edit(window, cx) {
1758 return;
1759 }
1760
1761 if let Some(selection) = self.selection
1762 && let Some(entry) = self.entries.get(selection)
1763 {
1764 match entry {
1765 ListEntry::Header(section) => match section {
1766 Section::ActiveCall => Self::leave_call(window, cx),
1767 Section::Channels => self.new_root_channel(window, cx),
1768 Section::Contacts => self.toggle_contact_finder(window, cx),
1769 Section::FavoriteChannels
1770 | Section::ContactRequests
1771 | Section::Online
1772 | Section::Offline
1773 | Section::ChannelInvites => {
1774 self.toggle_section_expanded(*section, cx);
1775 }
1776 },
1777 ListEntry::Contact { contact, calling } => {
1778 if contact.online && !contact.busy && !calling {
1779 self.call(contact.user.id, window, cx);
1780 }
1781 }
1782 ListEntry::ParticipantProject {
1783 project_id,
1784 host_user_id,
1785 ..
1786 } => {
1787 if let Some(workspace) = self.workspace.upgrade() {
1788 let app_state = workspace.read(cx).app_state().clone();
1789 workspace::join_in_room_project(*project_id, *host_user_id, app_state, cx)
1790 .detach_and_prompt_err(
1791 "Failed to join project",
1792 window,
1793 cx,
1794 |error, _, _| Some(format!("{error:#}")),
1795 );
1796 }
1797 }
1798 ListEntry::ParticipantScreen { peer_id, .. } => {
1799 let Some(peer_id) = peer_id else {
1800 return;
1801 };
1802 if let Some(workspace) = self.workspace.upgrade() {
1803 workspace.update(cx, |workspace, cx| {
1804 workspace.open_shared_screen(*peer_id, window, cx)
1805 });
1806 }
1807 }
1808 ListEntry::Channel { channel, .. } => {
1809 let is_active = maybe!({
1810 let call_channel = ActiveCall::global(cx)
1811 .read(cx)
1812 .room()?
1813 .read(cx)
1814 .channel_id()?;
1815
1816 Some(call_channel == channel.id)
1817 })
1818 .unwrap_or(false);
1819 if is_active {
1820 self.open_channel_notes(channel.id, window, cx)
1821 } else {
1822 self.join_channel(channel.id, window, cx)
1823 }
1824 }
1825 ListEntry::ContactPlaceholder => self.toggle_contact_finder(window, cx),
1826 ListEntry::CallParticipant { user, peer_id, .. } => {
1827 if Some(user) == self.user_store.read(cx).current_user().as_ref() {
1828 Self::leave_call(window, cx);
1829 } else if let Some(peer_id) = peer_id {
1830 self.workspace
1831 .update(cx, |workspace, cx| workspace.follow(*peer_id, window, cx))
1832 .ok();
1833 }
1834 }
1835 ListEntry::IncomingRequest(user) => {
1836 self.respond_to_contact_request(user.id, true, window, cx)
1837 }
1838 ListEntry::ChannelInvite(channel) => {
1839 self.respond_to_channel_invite(channel.id, true, cx)
1840 }
1841 ListEntry::ChannelNotes { channel_id } => {
1842 self.open_channel_notes(*channel_id, window, cx)
1843 }
1844 ListEntry::OutgoingRequest(_) => {}
1845 ListEntry::ChannelEditor { .. } => {}
1846 }
1847 }
1848 }
1849
1850 fn insert_space(&mut self, _: &InsertSpace, window: &mut Window, cx: &mut Context<Self>) {
1851 if self.channel_editing_state.is_some() {
1852 self.channel_name_editor.update(cx, |editor, cx| {
1853 editor.insert(" ", window, cx);
1854 });
1855 } else if self.filter_editor.focus_handle(cx).is_focused(window) {
1856 self.filter_editor.update(cx, |editor, cx| {
1857 editor.insert(" ", window, cx);
1858 });
1859 }
1860 }
1861
1862 fn confirm_channel_edit(&mut self, window: &mut Window, cx: &mut Context<CollabPanel>) -> bool {
1863 if let Some(editing_state) = &mut self.channel_editing_state {
1864 match editing_state {
1865 ChannelEditingState::Create {
1866 location,
1867 pending_name,
1868 ..
1869 } => {
1870 if pending_name.is_some() {
1871 return false;
1872 }
1873 let channel_name = self.channel_name_editor.read(cx).text(cx);
1874
1875 *pending_name = Some(channel_name.clone());
1876
1877 let create = self.channel_store.update(cx, |channel_store, cx| {
1878 channel_store.create_channel(&channel_name, *location, cx)
1879 });
1880 if location.is_none() {
1881 cx.spawn_in(window, async move |this, cx| {
1882 let channel_id = create.await?;
1883 this.update_in(cx, |this, window, cx| {
1884 this.show_channel_modal(
1885 channel_id,
1886 channel_modal::Mode::InviteMembers,
1887 window,
1888 cx,
1889 )
1890 })
1891 })
1892 .detach_and_prompt_err(
1893 "Failed to create channel",
1894 window,
1895 cx,
1896 |_, _, _| None,
1897 );
1898 } else {
1899 create.detach_and_prompt_err(
1900 "Failed to create channel",
1901 window,
1902 cx,
1903 |_, _, _| None,
1904 );
1905 }
1906 cx.notify();
1907 }
1908 ChannelEditingState::Rename {
1909 location,
1910 pending_name,
1911 } => {
1912 if pending_name.is_some() {
1913 return false;
1914 }
1915 let channel_name = self.channel_name_editor.read(cx).text(cx);
1916 *pending_name = Some(channel_name.clone());
1917
1918 self.channel_store
1919 .update(cx, |channel_store, cx| {
1920 channel_store.rename(*location, &channel_name, cx)
1921 })
1922 .detach();
1923 cx.notify();
1924 }
1925 }
1926 cx.focus_self(window);
1927 true
1928 } else {
1929 false
1930 }
1931 }
1932
1933 fn toggle_section_expanded(&mut self, section: Section, cx: &mut Context<Self>) {
1934 if let Some(ix) = self.collapsed_sections.iter().position(|s| *s == section) {
1935 self.collapsed_sections.remove(ix);
1936 } else {
1937 self.collapsed_sections.push(section);
1938 }
1939 self.update_entries(false, cx);
1940 }
1941
1942 fn collapse_selected_channel(
1943 &mut self,
1944 _: &CollapseSelectedChannel,
1945 window: &mut Window,
1946 cx: &mut Context<Self>,
1947 ) {
1948 let Some(channel_id) = self.selected_channel().map(|channel| channel.id) else {
1949 return;
1950 };
1951
1952 if self.is_channel_collapsed(channel_id) {
1953 return;
1954 }
1955
1956 self.toggle_channel_collapsed(channel_id, window, cx);
1957 }
1958
1959 fn expand_selected_channel(
1960 &mut self,
1961 _: &ExpandSelectedChannel,
1962 window: &mut Window,
1963 cx: &mut Context<Self>,
1964 ) {
1965 let Some(id) = self.selected_channel().map(|channel| channel.id) else {
1966 return;
1967 };
1968
1969 if !self.is_channel_collapsed(id) {
1970 return;
1971 }
1972
1973 self.toggle_channel_collapsed(id, window, cx)
1974 }
1975
1976 fn toggle_channel_collapsed(
1977 &mut self,
1978 channel_id: ChannelId,
1979 window: &mut Window,
1980 cx: &mut Context<Self>,
1981 ) {
1982 match self.collapsed_channels.binary_search(&channel_id) {
1983 Ok(ix) => {
1984 self.collapsed_channels.remove(ix);
1985 }
1986 Err(ix) => {
1987 self.collapsed_channels.insert(ix, channel_id);
1988 }
1989 };
1990 self.serialize(cx);
1991 self.update_entries(true, cx);
1992 cx.notify();
1993 cx.focus_self(window);
1994 }
1995
1996 fn is_channel_collapsed(&self, channel_id: ChannelId) -> bool {
1997 self.collapsed_channels.binary_search(&channel_id).is_ok()
1998 }
1999
2000 pub fn toggle_favorite_channel(&mut self, channel_id: ChannelId, cx: &mut Context<Self>) {
2001 self.channel_store.update(cx, |store, cx| {
2002 store.toggle_favorite_channel(channel_id, cx);
2003 });
2004 self.persist_favorites(cx);
2005 }
2006
2007 fn is_channel_favorited(&self, channel_id: ChannelId, cx: &App) -> bool {
2008 self.channel_store.read(cx).is_channel_favorited(channel_id)
2009 }
2010
2011 fn persist_filter_occupied_channels(&mut self, cx: &mut Context<Self>) {
2012 let is_enabled = self.filter_occupied_channels;
2013 let kvp_store = KeyValueStore::global(cx);
2014 self.pending_filter_serialization = cx.background_spawn(
2015 async move {
2016 if is_enabled {
2017 kvp_store
2018 .write_kvp(FILTER_OCCUPIED_CHANNELS_KEY.to_string(), "1".to_string())
2019 .await?;
2020 } else {
2021 kvp_store
2022 .delete_kvp(FILTER_OCCUPIED_CHANNELS_KEY.to_string())
2023 .await?;
2024 }
2025 anyhow::Ok(())
2026 }
2027 .log_err(),
2028 );
2029 }
2030
2031 fn persist_favorites(&mut self, cx: &mut Context<Self>) {
2032 let favorite_ids: Vec<u64> = self
2033 .channel_store
2034 .read(cx)
2035 .favorite_channel_ids()
2036 .iter()
2037 .map(|id| id.0)
2038 .collect();
2039 let kvp_store = KeyValueStore::global(cx);
2040 self.pending_favorites_serialization = cx.background_spawn(
2041 async move {
2042 let json = serde_json::to_string(&favorite_ids)?;
2043 kvp_store
2044 .write_kvp(FAVORITE_CHANNELS_KEY.to_string(), json)
2045 .await?;
2046 anyhow::Ok(())
2047 }
2048 .log_err(),
2049 );
2050 }
2051
2052 fn leave_call(window: &mut Window, cx: &mut App) {
2053 ActiveCall::global(cx)
2054 .update(cx, |call, cx| call.hang_up(cx))
2055 .detach_and_prompt_err("Failed to hang up", window, cx, |_, _, _| None);
2056 }
2057
2058 fn toggle_contact_finder(&mut self, window: &mut Window, cx: &mut Context<Self>) {
2059 if let Some(workspace) = self.workspace.upgrade() {
2060 workspace.update(cx, |workspace, cx| {
2061 workspace.toggle_modal(window, cx, |window, cx| {
2062 let mut finder = ContactFinder::new(self.user_store.clone(), window, cx);
2063 finder.set_query(self.filter_editor.read(cx).text(cx), window, cx);
2064 finder
2065 });
2066 });
2067 }
2068 }
2069
2070 fn new_root_channel(&mut self, window: &mut Window, cx: &mut Context<Self>) {
2071 self.channel_editing_state = Some(ChannelEditingState::Create {
2072 location: None,
2073 pending_name: None,
2074 });
2075 self.update_entries(false, cx);
2076 self.select_channel_editor();
2077 window.focus(&self.channel_name_editor.focus_handle(cx), cx);
2078 cx.notify();
2079 }
2080
2081 fn select_channel_editor(&mut self) {
2082 self.selection = self
2083 .entries
2084 .iter()
2085 .position(|entry| matches!(entry, ListEntry::ChannelEditor { .. }));
2086 }
2087
2088 fn new_subchannel(
2089 &mut self,
2090 channel_id: ChannelId,
2091 window: &mut Window,
2092 cx: &mut Context<Self>,
2093 ) {
2094 self.collapsed_channels
2095 .retain(|channel| *channel != channel_id);
2096 self.channel_editing_state = Some(ChannelEditingState::Create {
2097 location: Some(channel_id),
2098 pending_name: None,
2099 });
2100 self.update_entries(false, cx);
2101 self.select_channel_editor();
2102 window.focus(&self.channel_name_editor.focus_handle(cx), cx);
2103 cx.notify();
2104 }
2105
2106 fn manage_members(
2107 &mut self,
2108 channel_id: ChannelId,
2109 window: &mut Window,
2110 cx: &mut Context<Self>,
2111 ) {
2112 self.show_channel_modal(channel_id, channel_modal::Mode::ManageMembers, window, cx);
2113 }
2114
2115 fn remove_selected_channel(&mut self, _: &Remove, window: &mut Window, cx: &mut Context<Self>) {
2116 if let Some(channel) = self.selected_channel() {
2117 self.remove_channel(channel.id, window, cx)
2118 }
2119 }
2120
2121 fn rename_selected_channel(
2122 &mut self,
2123 _: &SecondaryConfirm,
2124 window: &mut Window,
2125 cx: &mut Context<Self>,
2126 ) {
2127 if let Some(channel) = self.selected_channel() {
2128 self.rename_channel(channel.id, window, cx);
2129 }
2130 }
2131
2132 fn rename_channel(
2133 &mut self,
2134 channel_id: ChannelId,
2135 window: &mut Window,
2136 cx: &mut Context<Self>,
2137 ) {
2138 let channel_store = self.channel_store.read(cx);
2139 if !channel_store.is_channel_admin(channel_id) {
2140 return;
2141 }
2142 if let Some(channel) = channel_store.channel_for_id(channel_id).cloned() {
2143 self.channel_editing_state = Some(ChannelEditingState::Rename {
2144 location: channel_id,
2145 pending_name: None,
2146 });
2147 self.channel_name_editor.update(cx, |editor, cx| {
2148 editor.set_text(channel.name.clone(), window, cx);
2149 editor.select_all(&Default::default(), window, cx);
2150 });
2151 window.focus(&self.channel_name_editor.focus_handle(cx), cx);
2152 self.update_entries(false, cx);
2153 self.select_channel_editor();
2154 }
2155 }
2156
2157 fn open_selected_channel_notes(
2158 &mut self,
2159 _: &OpenSelectedChannelNotes,
2160 window: &mut Window,
2161 cx: &mut Context<Self>,
2162 ) {
2163 if let Some(channel) = self.selected_channel() {
2164 self.open_channel_notes(channel.id, window, cx);
2165 }
2166 }
2167
2168 pub fn toggle_selected_channel_favorite(
2169 &mut self,
2170 _: &ToggleSelectedChannelFavorite,
2171 _window: &mut Window,
2172 cx: &mut Context<Self>,
2173 ) {
2174 if let Some(channel) = self.selected_channel() {
2175 self.toggle_favorite_channel(channel.id, cx);
2176 }
2177 }
2178
2179 fn set_channel_visibility(
2180 &mut self,
2181 channel_id: ChannelId,
2182 visibility: ChannelVisibility,
2183 window: &mut Window,
2184 cx: &mut Context<Self>,
2185 ) {
2186 self.channel_store
2187 .update(cx, |channel_store, cx| {
2188 channel_store.set_channel_visibility(channel_id, visibility, cx)
2189 })
2190 .detach_and_prompt_err("Failed to set channel visibility", window, cx, |e, _, _| match e.error_code() {
2191 ErrorCode::BadPublicNesting =>
2192 if e.error_tag("direction") == Some("parent") {
2193 Some("To make a channel public, its parent channel must be public.".to_string())
2194 } else {
2195 Some("To make a channel private, all of its subchannels must be private.".to_string())
2196 },
2197 _ => None
2198 });
2199 }
2200
2201 fn start_move_channel(
2202 &mut self,
2203 channel_id: ChannelId,
2204 _window: &mut Window,
2205 _cx: &mut Context<Self>,
2206 ) {
2207 self.channel_clipboard = Some(ChannelMoveClipboard { channel_id });
2208 }
2209
2210 fn start_move_selected_channel(
2211 &mut self,
2212 _: &StartMoveChannel,
2213 window: &mut Window,
2214 cx: &mut Context<Self>,
2215 ) {
2216 if let Some(channel) = self.selected_channel() {
2217 self.start_move_channel(channel.id, window, cx);
2218 }
2219 }
2220
2221 fn move_channel_on_clipboard(
2222 &mut self,
2223 to_channel_id: ChannelId,
2224 window: &mut Window,
2225 cx: &mut Context<CollabPanel>,
2226 ) {
2227 if let Some(clipboard) = self.channel_clipboard.take() {
2228 self.move_channel(clipboard.channel_id, to_channel_id, window, cx)
2229 }
2230 }
2231
2232 fn move_channel(
2233 &self,
2234 channel_id: ChannelId,
2235 to: ChannelId,
2236 window: &mut Window,
2237 cx: &mut Context<Self>,
2238 ) {
2239 self.channel_store
2240 .update(cx, |channel_store, cx| {
2241 channel_store.move_channel(channel_id, to, cx)
2242 })
2243 .detach_and_prompt_err("Failed to move channel", window, cx, |e, _, _| {
2244 match e.error_code() {
2245 ErrorCode::BadPublicNesting => {
2246 Some("Public channels must have public parents".into())
2247 }
2248 ErrorCode::CircularNesting => {
2249 Some("You cannot move a channel into itself".into())
2250 }
2251 ErrorCode::WrongMoveTarget => {
2252 Some("You cannot move a channel into a different root channel".into())
2253 }
2254 _ => None,
2255 }
2256 })
2257 }
2258
2259 pub fn move_channel_up(
2260 &mut self,
2261 _: &MoveChannelUp,
2262 window: &mut Window,
2263 cx: &mut Context<Self>,
2264 ) {
2265 self.reorder_selected_channel(Direction::Up, window, cx);
2266 }
2267
2268 pub fn move_channel_down(
2269 &mut self,
2270 _: &MoveChannelDown,
2271 window: &mut Window,
2272 cx: &mut Context<Self>,
2273 ) {
2274 self.reorder_selected_channel(Direction::Down, window, cx);
2275 }
2276
2277 fn reorder_selected_channel(
2278 &mut self,
2279 direction: Direction,
2280 window: &mut Window,
2281 cx: &mut Context<Self>,
2282 ) {
2283 if let Some(channel) = self.selected_channel().cloned() {
2284 if self.selected_entry_is_favorite() {
2285 self.reorder_favorite(channel.id, direction, cx);
2286 return;
2287 }
2288
2289 self.channel_store.update(cx, |store, cx| {
2290 store
2291 .reorder_channel(channel.id, direction, cx)
2292 .detach_and_prompt_err(
2293 match direction {
2294 Direction::Up => "Failed to move channel up",
2295 Direction::Down => "Failed to move channel down",
2296 },
2297 window,
2298 cx,
2299 |_, _, _| None,
2300 )
2301 });
2302 }
2303 }
2304
2305 pub fn reorder_favorite(
2306 &mut self,
2307 channel_id: ChannelId,
2308 direction: Direction,
2309 cx: &mut Context<Self>,
2310 ) {
2311 self.channel_store.update(cx, |store, cx| {
2312 let favorite_ids = store.favorite_channel_ids();
2313 let Some(channel_index) = favorite_ids.iter().position(|id| *id == channel_id) else {
2314 return;
2315 };
2316 let target_channel_index = match direction {
2317 Direction::Up => channel_index.checked_sub(1),
2318 Direction::Down => {
2319 let next = channel_index + 1;
2320 (next < favorite_ids.len()).then_some(next)
2321 }
2322 };
2323 if let Some(target_channel_index) = target_channel_index {
2324 let mut new_ids = favorite_ids.to_vec();
2325 new_ids.swap(channel_index, target_channel_index);
2326 store.set_favorite_channel_ids(new_ids, cx);
2327 }
2328 });
2329 self.persist_favorites(cx);
2330 }
2331
2332 fn open_channel_notes(
2333 &mut self,
2334 channel_id: ChannelId,
2335 window: &mut Window,
2336 cx: &mut Context<Self>,
2337 ) {
2338 if let Some(workspace) = self.workspace.upgrade() {
2339 ChannelView::open(channel_id, None, workspace, window, cx).detach();
2340 }
2341 }
2342
2343 fn show_inline_context_menu(
2344 &mut self,
2345 _: &Secondary,
2346 window: &mut Window,
2347 cx: &mut Context<Self>,
2348 ) {
2349 let Some(bounds) = self
2350 .selection
2351 .and_then(|ix| self.list_state.bounds_for_item(ix))
2352 else {
2353 return;
2354 };
2355
2356 if let Some(channel) = self.selected_channel() {
2357 self.deploy_channel_context_menu(
2358 bounds.center(),
2359 channel.id,
2360 self.selection.unwrap(),
2361 window,
2362 cx,
2363 );
2364 cx.stop_propagation();
2365 return;
2366 };
2367
2368 if let Some(contact) = self.selected_contact() {
2369 self.deploy_contact_context_menu(bounds.center(), contact, window, cx);
2370 cx.stop_propagation();
2371 }
2372 }
2373
2374 fn dispatch_context(&self, window: &Window, cx: &Context<Self>) -> KeyContext {
2375 let mut dispatch_context = KeyContext::new_with_defaults();
2376 dispatch_context.add("CollabPanel");
2377 dispatch_context.add("menu");
2378
2379 let identifier = if self.channel_name_editor.focus_handle(cx).is_focused(window)
2380 || self.filter_editor.focus_handle(cx).is_focused(window)
2381 {
2382 "editing"
2383 } else {
2384 "not_editing"
2385 };
2386
2387 dispatch_context.add(identifier);
2388 dispatch_context
2389 }
2390
2391 fn selected_channel(&self) -> Option<&Arc<Channel>> {
2392 self.selection
2393 .and_then(|ix| self.entries.get(ix))
2394 .and_then(|entry| match entry {
2395 ListEntry::Channel { channel, .. } => Some(channel),
2396 _ => None,
2397 })
2398 }
2399
2400 fn selected_entry_is_favorite(&self) -> bool {
2401 self.selection
2402 .and_then(|ix| self.entries.get(ix))
2403 .is_some_and(|entry| {
2404 matches!(
2405 entry,
2406 ListEntry::Channel {
2407 is_favorite: true,
2408 ..
2409 }
2410 )
2411 })
2412 }
2413
2414 fn selected_contact(&self) -> Option<Arc<Contact>> {
2415 self.selection
2416 .and_then(|ix| self.entries.get(ix))
2417 .and_then(|entry| match entry {
2418 ListEntry::Contact { contact, .. } => Some(contact.clone()),
2419 _ => None,
2420 })
2421 }
2422
2423 fn show_channel_modal(
2424 &mut self,
2425 channel_id: ChannelId,
2426 mode: channel_modal::Mode,
2427 window: &mut Window,
2428 cx: &mut Context<Self>,
2429 ) {
2430 let workspace = self.workspace.clone();
2431 let user_store = self.user_store.clone();
2432 let channel_store = self.channel_store.clone();
2433
2434 cx.spawn_in(window, async move |_, cx| {
2435 workspace.update_in(cx, |workspace, window, cx| {
2436 workspace.toggle_modal(window, cx, |window, cx| {
2437 ChannelModal::new(
2438 user_store.clone(),
2439 channel_store.clone(),
2440 channel_id,
2441 mode,
2442 window,
2443 cx,
2444 )
2445 });
2446 })
2447 })
2448 .detach();
2449 }
2450
2451 fn leave_channel(&self, channel_id: ChannelId, window: &mut Window, cx: &mut Context<Self>) {
2452 let Some(user_id) = self.user_store.read(cx).current_user().map(|u| u.id) else {
2453 return;
2454 };
2455 let Some(channel) = self.channel_store.read(cx).channel_for_id(channel_id) else {
2456 return;
2457 };
2458 let prompt_message = format!("Are you sure you want to leave \"#{}\"?", channel.name);
2459 let answer = window.prompt(
2460 PromptLevel::Warning,
2461 &prompt_message,
2462 None,
2463 &["Leave", "Cancel"],
2464 cx,
2465 );
2466 cx.spawn_in(window, async move |this, cx| {
2467 if answer.await? != 0 {
2468 return Ok(());
2469 }
2470 this.update(cx, |this, cx| {
2471 this.channel_store.update(cx, |channel_store, cx| {
2472 channel_store.remove_member(channel_id, user_id, cx)
2473 })
2474 })?
2475 .await
2476 })
2477 .detach_and_prompt_err("Failed to leave channel", window, cx, |_, _, _| None)
2478 }
2479
2480 fn remove_channel(
2481 &mut self,
2482 channel_id: ChannelId,
2483 window: &mut Window,
2484 cx: &mut Context<Self>,
2485 ) {
2486 let channel_store = self.channel_store.clone();
2487 if let Some(channel) = channel_store.read(cx).channel_for_id(channel_id) {
2488 let prompt_message = format!(
2489 "Are you sure you want to remove the channel \"{}\"?",
2490 channel.name
2491 );
2492 let answer = window.prompt(
2493 PromptLevel::Warning,
2494 &prompt_message,
2495 None,
2496 &["Remove", "Cancel"],
2497 cx,
2498 );
2499 let workspace = self.workspace.clone();
2500 cx.spawn_in(window, async move |this, mut cx| {
2501 if answer.await? == 0 {
2502 channel_store
2503 .update(cx, |channels, _| channels.remove_channel(channel_id))
2504 .await
2505 .notify_workspace_async_err(workspace, &mut cx);
2506 this.update_in(cx, |_, window, cx| cx.focus_self(window))
2507 .ok();
2508 }
2509 anyhow::Ok(())
2510 })
2511 .detach();
2512 }
2513 }
2514
2515 fn remove_contact(
2516 &mut self,
2517 user_id: u64,
2518 github_login: &str,
2519 window: &mut Window,
2520 cx: &mut Context<Self>,
2521 ) {
2522 let user_store = self.user_store.clone();
2523 let prompt_message = format!(
2524 "Are you sure you want to remove \"{}\" from your contacts?",
2525 github_login
2526 );
2527 let answer = window.prompt(
2528 PromptLevel::Warning,
2529 &prompt_message,
2530 None,
2531 &["Remove", "Cancel"],
2532 cx,
2533 );
2534 let workspace = self.workspace.clone();
2535 cx.spawn_in(window, async move |_, mut cx| {
2536 if answer.await? == 0 {
2537 user_store
2538 .update(cx, |store, cx| store.remove_contact(user_id, cx))
2539 .await
2540 .notify_workspace_async_err(workspace, &mut cx);
2541 }
2542 anyhow::Ok(())
2543 })
2544 .detach_and_prompt_err("Failed to remove contact", window, cx, |_, _, _| None);
2545 }
2546
2547 fn respond_to_contact_request(
2548 &mut self,
2549 user_id: u64,
2550 accept: bool,
2551 window: &mut Window,
2552 cx: &mut Context<Self>,
2553 ) {
2554 self.user_store
2555 .update(cx, |store, cx| {
2556 store.respond_to_contact_request(user_id, accept, cx)
2557 })
2558 .detach_and_prompt_err(
2559 "Failed to respond to contact request",
2560 window,
2561 cx,
2562 |_, _, _| None,
2563 );
2564 }
2565
2566 fn respond_to_channel_invite(
2567 &mut self,
2568 channel_id: ChannelId,
2569 accept: bool,
2570 cx: &mut Context<Self>,
2571 ) {
2572 self.channel_store
2573 .update(cx, |store, cx| {
2574 store.respond_to_channel_invite(channel_id, accept, cx)
2575 })
2576 .detach();
2577 }
2578
2579 fn call(&mut self, recipient_user_id: u64, window: &mut Window, cx: &mut Context<Self>) {
2580 ActiveCall::global(cx)
2581 .update(cx, |call, cx| {
2582 call.invite(recipient_user_id, Some(self.project.clone()), cx)
2583 })
2584 .detach_and_prompt_err("Call failed", window, cx, |_, _, _| None);
2585 }
2586
2587 fn join_channel(&self, channel_id: ChannelId, window: &mut Window, cx: &mut Context<Self>) {
2588 let Some(workspace) = self.workspace.upgrade() else {
2589 return;
2590 };
2591
2592 let Some(handle) = window.window_handle().downcast::<MultiWorkspace>() else {
2593 return;
2594 };
2595 workspace::join_channel(
2596 channel_id,
2597 workspace.read(cx).app_state().clone(),
2598 Some(handle),
2599 Some(self.workspace.clone()),
2600 cx,
2601 )
2602 .detach_and_prompt_err("Failed to join channel", window, cx, |_, _, _| None)
2603 }
2604
2605 fn copy_channel_link(&mut self, channel_id: ChannelId, cx: &mut Context<Self>) {
2606 let channel_store = self.channel_store.read(cx);
2607 let Some(channel) = channel_store.channel_for_id(channel_id) else {
2608 return;
2609 };
2610 let item = ClipboardItem::new_string(channel.link(cx));
2611 cx.write_to_clipboard(item)
2612 }
2613
2614 fn copy_channel_notes_link(&mut self, channel_id: ChannelId, cx: &mut Context<Self>) {
2615 let channel_store = self.channel_store.read(cx);
2616 let Some(channel) = channel_store.channel_for_id(channel_id) else {
2617 return;
2618 };
2619 let item = ClipboardItem::new_string(channel.notes_link(None, cx));
2620 cx.write_to_clipboard(item)
2621 }
2622
2623 fn render_signed_out(&mut self, cx: &mut Context<Self>) -> Div {
2624 let collab_blurb = "Work with your team in realtime with collaborative editing, voice, shared notes and more.";
2625
2626 // Two distinct "not connected" states:
2627 // - Authenticated (has credentials): user just needs to connect.
2628 // - Unauthenticated (no credentials): user needs to sign in via GitHub.
2629 let is_authenticated = self.client.user_id().is_some();
2630 let status = *self.client.status().borrow();
2631 let is_busy = status.is_signing_in();
2632
2633 let (button_id, button_label, button_icon) = if is_authenticated {
2634 (
2635 "connect",
2636 if is_busy { "Connecting…" } else { "Connect" },
2637 IconName::Public,
2638 )
2639 } else {
2640 (
2641 "sign_in",
2642 if is_busy {
2643 "Signing in…"
2644 } else {
2645 "Sign In with GitHub"
2646 },
2647 IconName::Github,
2648 )
2649 };
2650
2651 v_flex()
2652 .p_4()
2653 .gap_4()
2654 .size_full()
2655 .text_center()
2656 .justify_center()
2657 .child(Label::new(collab_blurb))
2658 .child(
2659 Button::new(button_id, button_label)
2660 .full_width()
2661 .start_icon(Icon::new(button_icon).color(Color::Muted))
2662 .style(ButtonStyle::Outlined)
2663 .disabled(is_busy)
2664 .on_click(cx.listener(|this, _, window, cx| {
2665 let client = this.client.clone();
2666 let workspace = this.workspace.clone();
2667 cx.spawn_in(window, async move |_, mut cx| {
2668 client
2669 .connect(true, &mut cx)
2670 .await
2671 .into_response()
2672 .notify_workspace_async_err(workspace, &mut cx);
2673 })
2674 .detach()
2675 })),
2676 )
2677 }
2678
2679 fn render_list_entry(
2680 &mut self,
2681 ix: usize,
2682 window: &mut Window,
2683 cx: &mut Context<Self>,
2684 ) -> AnyElement {
2685 let entry = self.entries[ix].clone();
2686
2687 let is_selected = self.selection == Some(ix);
2688 match entry {
2689 ListEntry::Header(section) => {
2690 let is_collapsed = self.collapsed_sections.contains(§ion);
2691 self.render_header(section, is_selected, is_collapsed, cx)
2692 .into_any_element()
2693 }
2694 ListEntry::Contact { contact, calling } => {
2695 self.mark_contact_request_accepted_notifications_read(contact.user.id, cx);
2696 self.render_contact(&contact, calling, is_selected, cx)
2697 .into_any_element()
2698 }
2699 ListEntry::ContactPlaceholder => self
2700 .render_contact_placeholder(is_selected, cx)
2701 .into_any_element(),
2702 ListEntry::IncomingRequest(user) => self
2703 .render_contact_request(&user, true, is_selected, cx)
2704 .into_any_element(),
2705 ListEntry::OutgoingRequest(user) => self
2706 .render_contact_request(&user, false, is_selected, cx)
2707 .into_any_element(),
2708 ListEntry::Channel {
2709 channel,
2710 depth,
2711 has_children,
2712 string_match,
2713 ..
2714 } => self
2715 .render_channel(
2716 &channel,
2717 depth,
2718 has_children,
2719 is_selected,
2720 ix,
2721 string_match.as_ref(),
2722 cx,
2723 )
2724 .into_any_element(),
2725 ListEntry::ChannelEditor { depth } => self
2726 .render_channel_editor(depth, window, cx)
2727 .into_any_element(),
2728 ListEntry::ChannelInvite(channel) => self
2729 .render_channel_invite(&channel, is_selected, cx)
2730 .into_any_element(),
2731 ListEntry::CallParticipant {
2732 user,
2733 peer_id,
2734 is_pending,
2735 role,
2736 } => self
2737 .render_call_participant(&user, peer_id, is_pending, role, is_selected, cx)
2738 .into_any_element(),
2739 ListEntry::ParticipantProject {
2740 project_id,
2741 worktree_root_names,
2742 host_user_id,
2743 is_last,
2744 } => self
2745 .render_participant_project(
2746 project_id,
2747 &worktree_root_names,
2748 host_user_id,
2749 is_last,
2750 is_selected,
2751 window,
2752 cx,
2753 )
2754 .into_any_element(),
2755 ListEntry::ParticipantScreen { peer_id, is_last } => self
2756 .render_participant_screen(peer_id, is_last, is_selected, window, cx)
2757 .into_any_element(),
2758 ListEntry::ChannelNotes { channel_id } => self
2759 .render_channel_notes(channel_id, is_selected, window, cx)
2760 .into_any_element(),
2761 }
2762 }
2763
2764 fn render_signed_in(&mut self, _: &mut Window, cx: &mut Context<Self>) -> Div {
2765 self.channel_store.update(cx, |channel_store, _| {
2766 channel_store.initialize();
2767 });
2768
2769 let has_query = !self.filter_editor.read(cx).text(cx).is_empty();
2770
2771 v_flex()
2772 .size_full()
2773 .gap_1()
2774 .child(
2775 h_flex()
2776 .p_2()
2777 .h(Tab::container_height(cx))
2778 .gap_1p5()
2779 .border_b_1()
2780 .border_color(cx.theme().colors().border)
2781 .child(
2782 Icon::new(IconName::MagnifyingGlass)
2783 .size(IconSize::Small)
2784 .color(Color::Muted),
2785 )
2786 .child(self.render_filter_input(&self.filter_editor, cx))
2787 .when(has_query, |this| {
2788 this.pr_2p5().child(
2789 IconButton::new("clear_filter", IconName::Close)
2790 .shape(IconButtonShape::Square)
2791 .tooltip(Tooltip::text("Clear Filter"))
2792 .on_click(cx.listener(|this, _, window, cx| {
2793 this.reset_filter_editor_text(window, cx);
2794 cx.notify();
2795 })),
2796 )
2797 }),
2798 )
2799 .child(
2800 list(
2801 self.list_state.clone(),
2802 cx.processor(Self::render_list_entry),
2803 )
2804 .size_full(),
2805 )
2806 }
2807
2808 fn render_filter_input(
2809 &self,
2810 editor: &Entity<Editor>,
2811 cx: &mut Context<Self>,
2812 ) -> impl IntoElement {
2813 let settings = ThemeSettings::get_global(cx);
2814 let text_style = TextStyle {
2815 color: if editor.read(cx).read_only(cx) {
2816 cx.theme().colors().text_disabled
2817 } else {
2818 cx.theme().colors().text
2819 },
2820 font_family: settings.ui_font.family.clone(),
2821 font_features: settings.ui_font.features.clone(),
2822 font_fallbacks: settings.ui_font.fallbacks.clone(),
2823 font_size: rems(0.875).into(),
2824 font_weight: settings.ui_font.weight,
2825 font_style: FontStyle::Normal,
2826 line_height: relative(1.3),
2827 ..Default::default()
2828 };
2829
2830 EditorElement::new(
2831 editor,
2832 EditorStyle {
2833 local_player: cx.theme().players().local(),
2834 text: text_style,
2835 ..Default::default()
2836 },
2837 )
2838 }
2839
2840 fn render_header(
2841 &self,
2842 section: Section,
2843 is_selected: bool,
2844 is_collapsed: bool,
2845 cx: &mut Context<Self>,
2846 ) -> impl IntoElement {
2847 let mut channel_link = None;
2848 let mut channel_tooltip_text = None;
2849 let mut channel_icon = None;
2850
2851 let text = match section {
2852 Section::ActiveCall => {
2853 let channel_name = maybe!({
2854 let channel_id = ActiveCall::global(cx).read(cx).channel_id(cx)?;
2855
2856 let channel = self.channel_store.read(cx).channel_for_id(channel_id)?;
2857
2858 channel_link = Some(channel.link(cx));
2859 (channel_icon, channel_tooltip_text) = match channel.visibility {
2860 proto::ChannelVisibility::Public => {
2861 (Some("icons/public.svg"), Some("Copy public channel link."))
2862 }
2863 proto::ChannelVisibility::Members => {
2864 (Some("icons/hash.svg"), Some("Copy private channel link."))
2865 }
2866 };
2867
2868 Some(channel.name.clone())
2869 });
2870
2871 if let Some(name) = channel_name {
2872 name
2873 } else {
2874 SharedString::from("Current Call")
2875 }
2876 }
2877 Section::FavoriteChannels => SharedString::from("Favorites"),
2878 Section::ContactRequests => SharedString::from("Requests"),
2879 Section::Contacts => SharedString::from("Contacts"),
2880 Section::Channels => SharedString::from("Channels"),
2881 Section::ChannelInvites => SharedString::from("Invites"),
2882 Section::Online => SharedString::from("Online"),
2883 Section::Offline => SharedString::from("Offline"),
2884 };
2885
2886 let button = match section {
2887 Section::ActiveCall => channel_link.map(|channel_link| {
2888 CopyButton::new("copy-channel-link", channel_link)
2889 .visible_on_hover("section-header")
2890 .tooltip_label("Copy Channel Link")
2891 .into_any_element()
2892 }),
2893 Section::Contacts => Some(
2894 IconButton::new("add-contact", IconName::Plus)
2895 .icon_size(IconSize::Small)
2896 .on_click(
2897 cx.listener(|this, _, window, cx| this.toggle_contact_finder(window, cx)),
2898 )
2899 .tooltip(Tooltip::text("Search for new contact"))
2900 .into_any_element(),
2901 ),
2902 Section::Channels => {
2903 Some(
2904 h_flex()
2905 .child(
2906 IconButton::new("filter-occupied-channels", IconName::ListFilter)
2907 .icon_size(IconSize::Small)
2908 .toggle_state(self.filter_occupied_channels)
2909 .on_click(cx.listener(|this, _, _window, cx| {
2910 this.filter_occupied_channels = !this.filter_occupied_channels;
2911 this.update_entries(true, cx);
2912 this.persist_filter_occupied_channels(cx);
2913 }))
2914 .tooltip(Tooltip::text(if self.filter_occupied_channels {
2915 "Show All Channels"
2916 } else {
2917 "Show Occupied Channels"
2918 })),
2919 )
2920 .child(
2921 IconButton::new("add-channel", IconName::Plus)
2922 .icon_size(IconSize::Small)
2923 .on_click(cx.listener(|this, _, window, cx| {
2924 this.new_root_channel(window, cx)
2925 }))
2926 .tooltip(Tooltip::text("Create Channel")),
2927 )
2928 .into_any_element(),
2929 )
2930 }
2931 _ => None,
2932 };
2933
2934 let can_collapse = match section {
2935 Section::ActiveCall
2936 | Section::Channels
2937 | Section::Contacts
2938 | Section::FavoriteChannels => false,
2939
2940 Section::ChannelInvites
2941 | Section::ContactRequests
2942 | Section::Online
2943 | Section::Offline => true,
2944 };
2945
2946 h_flex().w_full().group("section-header").child(
2947 ListHeader::new(text)
2948 .when(can_collapse, |header| {
2949 header.toggle(Some(!is_collapsed)).on_toggle(cx.listener(
2950 move |this, _, _, cx| {
2951 this.toggle_section_expanded(section, cx);
2952 },
2953 ))
2954 })
2955 .inset(true)
2956 .end_slot::<AnyElement>(button)
2957 .toggle_state(is_selected),
2958 )
2959 }
2960
2961 fn render_contact(
2962 &self,
2963 contact: &Arc<Contact>,
2964 calling: bool,
2965 is_selected: bool,
2966 cx: &mut Context<Self>,
2967 ) -> impl IntoElement {
2968 let online = contact.online;
2969 let busy = contact.busy || calling;
2970 let github_login = contact.user.github_login.clone();
2971 let item = ListItem::new(github_login.clone())
2972 .indent_level(1)
2973 .indent_step_size(px(20.))
2974 .toggle_state(is_selected)
2975 .child(
2976 h_flex()
2977 .w_full()
2978 .justify_between()
2979 .child(render_participant_name_and_handle(&contact.user))
2980 .when(calling, |el| {
2981 el.child(Label::new("Calling").color(Color::Muted))
2982 })
2983 .when(!calling, |el| {
2984 el.child(
2985 IconButton::new("contact context menu", IconName::Ellipsis)
2986 .icon_color(Color::Muted)
2987 .visible_on_hover("")
2988 .on_click(cx.listener({
2989 let contact = contact.clone();
2990 move |this, event: &ClickEvent, window, cx| {
2991 this.deploy_contact_context_menu(
2992 event.position(),
2993 contact.clone(),
2994 window,
2995 cx,
2996 );
2997 }
2998 })),
2999 )
3000 }),
3001 )
3002 .on_secondary_mouse_down(cx.listener({
3003 let contact = contact.clone();
3004 move |this, event: &MouseDownEvent, window, cx| {
3005 this.deploy_contact_context_menu(event.position, contact.clone(), window, cx);
3006 }
3007 }))
3008 .start_slot(
3009 // todo handle contacts with no avatar
3010 Avatar::new(contact.user.avatar_uri.clone())
3011 .indicator::<AvatarAvailabilityIndicator>(if online {
3012 Some(AvatarAvailabilityIndicator::new(match busy {
3013 true => ui::CollaboratorAvailability::Busy,
3014 false => ui::CollaboratorAvailability::Free,
3015 }))
3016 } else {
3017 None
3018 }),
3019 );
3020
3021 div()
3022 .id(github_login.clone())
3023 .group("")
3024 .child(item)
3025 .tooltip(move |_, cx| {
3026 let text = if !online {
3027 format!(" {} is offline", &github_login)
3028 } else if busy {
3029 format!(" {} is on a call", &github_login)
3030 } else {
3031 let room = ActiveCall::global(cx).read(cx).room();
3032 if room.is_some() {
3033 format!("Invite {} to join call", &github_login)
3034 } else {
3035 format!("Call {}", &github_login)
3036 }
3037 };
3038 Tooltip::simple(text, cx)
3039 })
3040 }
3041
3042 fn render_contact_request(
3043 &self,
3044 user: &Arc<User>,
3045 is_incoming: bool,
3046 is_selected: bool,
3047 cx: &mut Context<Self>,
3048 ) -> impl IntoElement {
3049 let github_login = user.github_login.clone();
3050 let user_id = user.id;
3051 let is_response_pending = self.user_store.read(cx).is_contact_request_pending(user);
3052 let color = if is_response_pending {
3053 Color::Muted
3054 } else {
3055 Color::Default
3056 };
3057
3058 let controls = if is_incoming {
3059 vec![
3060 IconButton::new("decline-contact", IconName::Close)
3061 .on_click(cx.listener(move |this, _, window, cx| {
3062 this.respond_to_contact_request(user_id, false, window, cx);
3063 }))
3064 .icon_color(color)
3065 .tooltip(Tooltip::text("Decline invite")),
3066 IconButton::new("accept-contact", IconName::Check)
3067 .on_click(cx.listener(move |this, _, window, cx| {
3068 this.respond_to_contact_request(user_id, true, window, cx);
3069 }))
3070 .icon_color(color)
3071 .tooltip(Tooltip::text("Accept invite")),
3072 ]
3073 } else {
3074 let github_login = github_login.clone();
3075 vec![
3076 IconButton::new("remove_contact", IconName::Close)
3077 .on_click(cx.listener(move |this, _, window, cx| {
3078 this.remove_contact(user_id, &github_login, window, cx);
3079 }))
3080 .icon_color(color)
3081 .tooltip(Tooltip::text("Cancel invite")),
3082 ]
3083 };
3084
3085 ListItem::new(github_login.clone())
3086 .indent_level(1)
3087 .indent_step_size(px(20.))
3088 .toggle_state(is_selected)
3089 .child(
3090 h_flex()
3091 .w_full()
3092 .justify_between()
3093 .child(Label::new(github_login))
3094 .child(h_flex().children(controls)),
3095 )
3096 .start_slot(Avatar::new(user.avatar_uri.clone()))
3097 }
3098
3099 fn render_channel_invite(
3100 &self,
3101 channel: &Arc<Channel>,
3102 is_selected: bool,
3103 cx: &mut Context<Self>,
3104 ) -> ListItem {
3105 let channel_id = channel.id;
3106 let response_is_pending = self
3107 .channel_store
3108 .read(cx)
3109 .has_pending_channel_invite_response(channel);
3110 let color = if response_is_pending {
3111 Color::Muted
3112 } else {
3113 Color::Default
3114 };
3115
3116 let controls = [
3117 IconButton::new("reject-invite", IconName::Close)
3118 .on_click(cx.listener(move |this, _, _, cx| {
3119 this.respond_to_channel_invite(channel_id, false, cx);
3120 }))
3121 .icon_color(color)
3122 .tooltip(Tooltip::text("Decline invite")),
3123 IconButton::new("accept-invite", IconName::Check)
3124 .on_click(cx.listener(move |this, _, _, cx| {
3125 this.respond_to_channel_invite(channel_id, true, cx);
3126 }))
3127 .icon_color(color)
3128 .tooltip(Tooltip::text("Accept invite")),
3129 ];
3130
3131 ListItem::new(("channel-invite", channel.id.0 as usize))
3132 .toggle_state(is_selected)
3133 .child(
3134 h_flex()
3135 .w_full()
3136 .justify_between()
3137 .child(Label::new(channel.name.clone()))
3138 .child(h_flex().children(controls)),
3139 )
3140 .start_slot(
3141 Icon::new(IconName::Hash)
3142 .size(IconSize::Small)
3143 .color(Color::Muted),
3144 )
3145 }
3146
3147 fn render_contact_placeholder(&self, is_selected: bool, cx: &mut Context<Self>) -> ListItem {
3148 ListItem::new("contact-placeholder")
3149 .child(Icon::new(IconName::Plus))
3150 .child(Label::new("Add a Contact"))
3151 .toggle_state(is_selected)
3152 .on_click(cx.listener(|this, _, window, cx| this.toggle_contact_finder(window, cx)))
3153 }
3154
3155 fn render_channel(
3156 &self,
3157 channel: &Channel,
3158 depth: usize,
3159 has_children: bool,
3160 is_selected: bool,
3161 ix: usize,
3162 string_match: Option<&StringMatch>,
3163 cx: &mut Context<Self>,
3164 ) -> impl IntoElement {
3165 let channel_id = channel.id;
3166
3167 let is_active = maybe!({
3168 let call_channel = ActiveCall::global(cx)
3169 .read(cx)
3170 .room()?
3171 .read(cx)
3172 .channel_id()?;
3173 Some(call_channel == channel_id)
3174 })
3175 .unwrap_or(false);
3176 let channel_store = self.channel_store.read(cx);
3177 let is_public = channel_store
3178 .channel_for_id(channel_id)
3179 .map(|channel| channel.visibility)
3180 == Some(proto::ChannelVisibility::Public);
3181 let disclosed =
3182 has_children.then(|| self.collapsed_channels.binary_search(&channel.id).is_err());
3183
3184 let has_notes_notification = channel_store.has_channel_buffer_changed(channel_id);
3185
3186 const FACEPILE_LIMIT: usize = 3;
3187 let participants = self.channel_store.read(cx).channel_participants(channel_id);
3188
3189 let face_pile = if participants.is_empty() {
3190 None
3191 } else {
3192 let extra_count = participants.len().saturating_sub(FACEPILE_LIMIT);
3193 let result = Facepile::new(
3194 participants
3195 .iter()
3196 .map(|user| Avatar::new(user.avatar_uri.clone()).into_any_element())
3197 .take(FACEPILE_LIMIT)
3198 .chain(if extra_count > 0 {
3199 Some(
3200 Label::new(format!("+{extra_count}"))
3201 .ml_2()
3202 .into_any_element(),
3203 )
3204 } else {
3205 None
3206 })
3207 .collect::<SmallVec<_>>(),
3208 );
3209
3210 Some(result)
3211 };
3212
3213 let width = self
3214 .workspace
3215 .read_with(cx, |workspace, cx| {
3216 workspace
3217 .panel_size_state::<Self>(cx)
3218 .and_then(|size_state| size_state.size)
3219 })
3220 .ok()
3221 .flatten()
3222 .unwrap_or(px(240.));
3223 let root_id = channel.root_id();
3224
3225 let is_favorited = self.is_channel_favorited(channel_id, cx);
3226 let (favorite_icon, favorite_color, favorite_tooltip) = if is_favorited {
3227 (IconName::StarFilled, Color::Accent, "Remove from Favorites")
3228 } else {
3229 (IconName::Star, Color::Default, "Add to Favorites")
3230 };
3231
3232 let height = rems_from_px(24.);
3233
3234 h_flex()
3235 .id(ix)
3236 .group("")
3237 .h(height)
3238 .w_full()
3239 .overflow_hidden()
3240 .when(!channel.is_root_channel(), |el| {
3241 el.on_drag(channel.clone(), move |channel, _, _, cx| {
3242 cx.new(|_| DraggedChannelView {
3243 channel: channel.clone(),
3244 width,
3245 })
3246 })
3247 })
3248 .drag_over::<Channel>({
3249 move |style, dragged_channel: &Channel, _window, cx| {
3250 if dragged_channel.root_id() == root_id {
3251 style.bg(cx.theme().colors().ghost_element_hover)
3252 } else {
3253 style
3254 }
3255 }
3256 })
3257 .on_drop(
3258 cx.listener(move |this, dragged_channel: &Channel, window, cx| {
3259 if dragged_channel.root_id() != root_id {
3260 return;
3261 }
3262 this.move_channel(dragged_channel.id, channel_id, window, cx);
3263 }),
3264 )
3265 .child(
3266 ListItem::new(ix)
3267 .height(height)
3268 // Add one level of depth for the disclosure arrow.
3269 .indent_level(depth + 1)
3270 .indent_step_size(px(20.))
3271 .toggle_state(is_selected || is_active)
3272 .toggle(disclosed)
3273 .on_toggle(cx.listener(move |this, _, window, cx| {
3274 this.toggle_channel_collapsed(channel_id, window, cx)
3275 }))
3276 .on_click(cx.listener(move |this, _, window, cx| {
3277 if is_active {
3278 this.open_channel_notes(channel_id, window, cx)
3279 } else {
3280 this.join_channel(channel_id, window, cx)
3281 }
3282 }))
3283 .on_secondary_mouse_down(cx.listener(
3284 move |this, event: &MouseDownEvent, window, cx| {
3285 this.deploy_channel_context_menu(
3286 event.position,
3287 channel_id,
3288 ix,
3289 window,
3290 cx,
3291 )
3292 },
3293 ))
3294 .child(
3295 h_flex()
3296 .id(format!("inside-{}", channel_id.0))
3297 .w_full()
3298 .gap_1()
3299 .child(
3300 div()
3301 .relative()
3302 .child(
3303 Icon::new(if is_public {
3304 IconName::Public
3305 } else {
3306 IconName::Hash
3307 })
3308 .size(IconSize::Small)
3309 .color(Color::Muted),
3310 )
3311 .children(has_notes_notification.then(|| {
3312 div()
3313 .w_1p5()
3314 .absolute()
3315 .right(px(-1.))
3316 .top(px(-1.))
3317 .child(Indicator::dot().color(Color::Info))
3318 })),
3319 )
3320 .child(
3321 h_flex()
3322 .id(channel_id.0 as usize)
3323 .child(match string_match {
3324 None => Label::new(channel.name.clone()).into_any_element(),
3325 Some(string_match) => HighlightedLabel::new(
3326 channel.name.clone(),
3327 string_match.positions.clone(),
3328 )
3329 .into_any_element(),
3330 })
3331 .children(face_pile.map(|face_pile| face_pile.p_1())),
3332 )
3333 .tooltip({
3334 let channel_store = self.channel_store.clone();
3335 move |_window, cx| {
3336 cx.new(|_| JoinChannelTooltip {
3337 channel_store: channel_store.clone(),
3338 channel_id,
3339 has_notes_notification,
3340 })
3341 .into()
3342 }
3343 }),
3344 ),
3345 )
3346 .child(
3347 h_flex()
3348 .visible_on_hover("")
3349 .h_full()
3350 .absolute()
3351 .right_0()
3352 .px_1()
3353 .gap_px()
3354 .rounded_l_md()
3355 .bg(cx.theme().colors().background)
3356 .child({
3357 let focus_handle = self.focus_handle.clone();
3358 IconButton::new("channel_favorite", favorite_icon)
3359 .icon_size(IconSize::Small)
3360 .icon_color(favorite_color)
3361 .on_click(cx.listener(move |this, _, _window, cx| {
3362 this.toggle_favorite_channel(channel_id, cx)
3363 }))
3364 .tooltip(move |_window, cx| {
3365 Tooltip::for_action_in(
3366 favorite_tooltip,
3367 &ToggleSelectedChannelFavorite,
3368 &focus_handle,
3369 cx,
3370 )
3371 })
3372 })
3373 .child({
3374 let focus_handle = self.focus_handle.clone();
3375 IconButton::new("channel_notes", IconName::Reader)
3376 .icon_size(IconSize::Small)
3377 .on_click(cx.listener(move |this, _, window, cx| {
3378 this.open_channel_notes(channel_id, window, cx)
3379 }))
3380 .tooltip(move |_window, cx| {
3381 Tooltip::for_action_in(
3382 "Open Channel Notes",
3383 &OpenSelectedChannelNotes,
3384 &focus_handle,
3385 cx,
3386 )
3387 })
3388 }),
3389 )
3390 }
3391
3392 fn render_channel_editor(
3393 &self,
3394 depth: usize,
3395 _window: &mut Window,
3396 _cx: &mut Context<Self>,
3397 ) -> impl IntoElement {
3398 let item = ListItem::new("channel-editor")
3399 .inset(false)
3400 // Add one level of depth for the disclosure arrow.
3401 .indent_level(depth + 1)
3402 .indent_step_size(px(20.))
3403 .start_slot(
3404 Icon::new(IconName::Hash)
3405 .size(IconSize::Small)
3406 .color(Color::Muted),
3407 );
3408
3409 if let Some(pending_name) = self
3410 .channel_editing_state
3411 .as_ref()
3412 .and_then(|state| state.pending_name())
3413 {
3414 item.child(Label::new(pending_name))
3415 } else {
3416 item.child(self.channel_name_editor.clone())
3417 }
3418 }
3419
3420 fn on_notification_event(
3421 &mut self,
3422 _: &Entity<NotificationStore>,
3423 event: &NotificationEvent,
3424 _window: &mut Window,
3425 cx: &mut Context<Self>,
3426 ) {
3427 match event {
3428 NotificationEvent::NewNotification { entry } => {
3429 self.add_toast(entry, cx);
3430 cx.notify();
3431 }
3432 NotificationEvent::NotificationRemoved { entry }
3433 | NotificationEvent::NotificationRead { entry } => {
3434 self.remove_toast(entry.id, cx);
3435 cx.notify();
3436 }
3437 NotificationEvent::NotificationsUpdated { .. } => {
3438 cx.notify();
3439 }
3440 }
3441 }
3442
3443 fn present_notification(
3444 &self,
3445 entry: &NotificationEntry,
3446 cx: &App,
3447 ) -> Option<(Option<Arc<User>>, String)> {
3448 let user_store = self.user_store.read(cx);
3449 match &entry.notification {
3450 Notification::ContactRequest { sender_id } => {
3451 let requester = user_store.get_cached_user(*sender_id)?;
3452 Some((
3453 Some(requester.clone()),
3454 format!("{} wants to add you as a contact", requester.github_login),
3455 ))
3456 }
3457 Notification::ContactRequestAccepted { responder_id } => {
3458 let responder = user_store.get_cached_user(*responder_id)?;
3459 Some((
3460 Some(responder.clone()),
3461 format!("{} accepted your contact request", responder.github_login),
3462 ))
3463 }
3464 Notification::ChannelInvitation {
3465 channel_name,
3466 inviter_id,
3467 ..
3468 } => {
3469 let inviter = user_store.get_cached_user(*inviter_id)?;
3470 Some((
3471 Some(inviter.clone()),
3472 format!(
3473 "{} invited you to join the #{channel_name} channel",
3474 inviter.github_login
3475 ),
3476 ))
3477 }
3478 }
3479 }
3480
3481 fn add_toast(&mut self, entry: &NotificationEntry, cx: &mut Context<Self>) {
3482 let Some((actor, text)) = self.present_notification(entry, cx) else {
3483 return;
3484 };
3485
3486 let notification = entry.notification.clone();
3487 let needs_response = matches!(
3488 notification,
3489 Notification::ContactRequest { .. } | Notification::ChannelInvitation { .. }
3490 );
3491
3492 let notification_id = entry.id;
3493
3494 self.current_notification_toast = Some((
3495 notification_id,
3496 cx.spawn(async move |this, cx| {
3497 cx.background_executor().timer(TOAST_DURATION).await;
3498 this.update(cx, |this, cx| this.remove_toast(notification_id, cx))
3499 .ok();
3500 }),
3501 ));
3502
3503 let collab_panel = cx.entity().downgrade();
3504 self.workspace
3505 .update(cx, |workspace, cx| {
3506 let id = NotificationId::unique::<CollabNotificationToast>();
3507
3508 workspace.dismiss_notification(&id, cx);
3509 workspace.show_notification(id, cx, |cx| {
3510 let workspace = cx.entity().downgrade();
3511 cx.new(|cx| CollabNotificationToast {
3512 actor,
3513 text,
3514 notification: needs_response.then(|| notification),
3515 workspace,
3516 collab_panel: collab_panel.clone(),
3517 focus_handle: cx.focus_handle(),
3518 })
3519 })
3520 })
3521 .ok();
3522 }
3523
3524 fn mark_notification_read(&mut self, notification_id: u64, cx: &mut Context<Self>) {
3525 let client = self.client.clone();
3526 self.mark_as_read_tasks
3527 .entry(notification_id)
3528 .or_insert_with(|| {
3529 cx.spawn(async move |this, cx| {
3530 let request_result = client
3531 .request(proto::MarkNotificationRead { notification_id })
3532 .await;
3533
3534 this.update(cx, |this, _| {
3535 this.mark_as_read_tasks.remove(¬ification_id);
3536 })?;
3537
3538 request_result?;
3539 Ok(())
3540 })
3541 });
3542 }
3543
3544 fn mark_contact_request_accepted_notifications_read(
3545 &mut self,
3546 contact_user_id: u64,
3547 cx: &mut Context<Self>,
3548 ) {
3549 let notification_ids = self.notification_store.read_with(cx, |store, _| {
3550 (0..store.notification_count())
3551 .filter_map(|index| {
3552 let entry = store.notification_at(index)?;
3553 if entry.is_read {
3554 return None;
3555 }
3556
3557 match &entry.notification {
3558 Notification::ContactRequestAccepted { responder_id }
3559 if *responder_id == contact_user_id =>
3560 {
3561 Some(entry.id)
3562 }
3563 _ => None,
3564 }
3565 })
3566 .collect::<Vec<_>>()
3567 });
3568
3569 for notification_id in notification_ids {
3570 self.mark_notification_read(notification_id, cx);
3571 }
3572 }
3573
3574 fn remove_toast(&mut self, notification_id: u64, cx: &mut Context<Self>) {
3575 if let Some((current_id, _)) = &self.current_notification_toast {
3576 if *current_id == notification_id {
3577 self.dismiss_toast(cx);
3578 }
3579 }
3580 }
3581
3582 fn dismiss_toast(&mut self, cx: &mut Context<Self>) {
3583 self.current_notification_toast.take();
3584 self.workspace
3585 .update(cx, |workspace, cx| {
3586 let id = NotificationId::unique::<CollabNotificationToast>();
3587 workspace.dismiss_notification(&id, cx)
3588 })
3589 .ok();
3590 }
3591}
3592
3593fn render_tree_branch(
3594 is_last: bool,
3595 overdraw: bool,
3596 window: &mut Window,
3597 cx: &mut App,
3598) -> impl IntoElement {
3599 let rem_size = window.rem_size();
3600 let line_height = window.text_style().line_height_in_pixels(rem_size);
3601 let thickness = px(1.);
3602 let color = cx.theme().colors().icon_disabled;
3603
3604 canvas(
3605 |_, _, _| {},
3606 move |bounds, _, window, _| {
3607 let start_x = (bounds.left() + bounds.right() - thickness) / 2.;
3608 let start_y = (bounds.top() + bounds.bottom() - thickness) / 2.;
3609 let right = bounds.right();
3610 let top = bounds.top();
3611
3612 window.paint_quad(fill(
3613 Bounds::from_corners(
3614 point(start_x, top),
3615 point(
3616 start_x + thickness,
3617 if is_last {
3618 start_y
3619 } else {
3620 bounds.bottom() + if overdraw { px(1.) } else { px(0.) }
3621 },
3622 ),
3623 ),
3624 color,
3625 ));
3626 window.paint_quad(fill(
3627 Bounds::from_corners(point(start_x, start_y), point(right, start_y + thickness)),
3628 color,
3629 ));
3630 },
3631 )
3632 .w(rem_size)
3633 .h(line_height - px(2.))
3634}
3635
3636fn render_participant_name_and_handle(user: &User) -> impl IntoElement {
3637 Label::new(if let Some(ref display_name) = user.name {
3638 format!("{display_name} ({})", user.github_login)
3639 } else {
3640 user.github_login.to_string()
3641 })
3642}
3643
3644impl Render for CollabPanel {
3645 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
3646 let status = *self.client.status().borrow();
3647
3648 v_flex()
3649 .key_context(self.dispatch_context(window, cx))
3650 .on_action(cx.listener(CollabPanel::cancel))
3651 .on_action(cx.listener(CollabPanel::select_next))
3652 .on_action(cx.listener(CollabPanel::select_previous))
3653 .on_action(cx.listener(CollabPanel::confirm))
3654 .on_action(cx.listener(CollabPanel::insert_space))
3655 .on_action(cx.listener(CollabPanel::remove_selected_channel))
3656 .on_action(cx.listener(CollabPanel::show_inline_context_menu))
3657 .on_action(cx.listener(CollabPanel::rename_selected_channel))
3658 .on_action(cx.listener(CollabPanel::open_selected_channel_notes))
3659 .on_action(cx.listener(CollabPanel::toggle_selected_channel_favorite))
3660 .on_action(cx.listener(CollabPanel::collapse_selected_channel))
3661 .on_action(cx.listener(CollabPanel::expand_selected_channel))
3662 .on_action(cx.listener(CollabPanel::start_move_selected_channel))
3663 .on_action(cx.listener(CollabPanel::move_channel_up))
3664 .on_action(cx.listener(CollabPanel::move_channel_down))
3665 .track_focus(&self.focus_handle)
3666 .size_full()
3667 .child(if !status.is_or_was_connected() || status.is_signing_in() {
3668 self.render_signed_out(cx)
3669 } else {
3670 self.render_signed_in(window, cx)
3671 })
3672 .children(self.context_menu.as_ref().map(|(menu, position, _)| {
3673 deferred(
3674 anchored()
3675 .position(*position)
3676 .anchor(gpui::Corner::TopLeft)
3677 .child(menu.clone()),
3678 )
3679 .with_priority(1)
3680 }))
3681 }
3682}
3683
3684impl EventEmitter<PanelEvent> for CollabPanel {}
3685
3686impl Panel for CollabPanel {
3687 fn position(&self, _window: &Window, cx: &App) -> DockPosition {
3688 CollaborationPanelSettings::get_global(cx).dock
3689 }
3690
3691 fn position_is_valid(&self, position: DockPosition) -> bool {
3692 matches!(position, DockPosition::Left | DockPosition::Right)
3693 }
3694
3695 fn set_position(
3696 &mut self,
3697 position: DockPosition,
3698 _window: &mut Window,
3699 cx: &mut Context<Self>,
3700 ) {
3701 settings::update_settings_file(self.fs.clone(), cx, move |settings, _| {
3702 settings.collaboration_panel.get_or_insert_default().dock = Some(position.into())
3703 });
3704 }
3705
3706 fn default_size(&self, _window: &Window, cx: &App) -> Pixels {
3707 CollaborationPanelSettings::get_global(cx).default_width
3708 }
3709
3710 fn set_active(&mut self, active: bool, _window: &mut Window, cx: &mut Context<Self>) {
3711 if active && self.current_notification_toast.is_some() {
3712 self.current_notification_toast.take();
3713 let workspace = self.workspace.clone();
3714 cx.defer(move |cx| {
3715 workspace
3716 .update(cx, |workspace, cx| {
3717 let id = NotificationId::unique::<CollabNotificationToast>();
3718 workspace.dismiss_notification(&id, cx)
3719 })
3720 .ok();
3721 });
3722 }
3723 }
3724
3725 fn icon(&self, _window: &Window, cx: &App) -> Option<ui::IconName> {
3726 CollaborationPanelSettings::get_global(cx)
3727 .button
3728 .then_some(ui::IconName::UserGroup)
3729 }
3730
3731 fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> {
3732 Some("Collab Panel")
3733 }
3734
3735 fn toggle_action(&self) -> Box<dyn gpui::Action> {
3736 Box::new(ToggleFocus)
3737 }
3738
3739 fn persistent_name() -> &'static str {
3740 "CollabPanel"
3741 }
3742
3743 fn panel_key() -> &'static str {
3744 COLLABORATION_PANEL_KEY
3745 }
3746
3747 fn activation_priority(&self) -> u32 {
3748 5
3749 }
3750}
3751
3752impl Focusable for CollabPanel {
3753 fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
3754 self.filter_editor.focus_handle(cx)
3755 }
3756}
3757
3758impl PartialEq for ListEntry {
3759 fn eq(&self, other: &Self) -> bool {
3760 match self {
3761 ListEntry::Header(section_1) => {
3762 if let ListEntry::Header(section_2) = other {
3763 return section_1 == section_2;
3764 }
3765 }
3766 ListEntry::CallParticipant { user: user_1, .. } => {
3767 if let ListEntry::CallParticipant { user: user_2, .. } = other {
3768 return user_1.id == user_2.id;
3769 }
3770 }
3771 ListEntry::ParticipantProject {
3772 project_id: project_id_1,
3773 ..
3774 } => {
3775 if let ListEntry::ParticipantProject {
3776 project_id: project_id_2,
3777 ..
3778 } = other
3779 {
3780 return project_id_1 == project_id_2;
3781 }
3782 }
3783 ListEntry::ParticipantScreen {
3784 peer_id: peer_id_1, ..
3785 } => {
3786 if let ListEntry::ParticipantScreen {
3787 peer_id: peer_id_2, ..
3788 } = other
3789 {
3790 return peer_id_1 == peer_id_2;
3791 }
3792 }
3793 ListEntry::Channel {
3794 channel: channel_1,
3795 is_favorite: is_favorite_1,
3796 ..
3797 } => {
3798 if let ListEntry::Channel {
3799 channel: channel_2,
3800 is_favorite: is_favorite_2,
3801 ..
3802 } = other
3803 {
3804 return channel_1.id == channel_2.id && is_favorite_1 == is_favorite_2;
3805 }
3806 }
3807 ListEntry::ChannelNotes { channel_id } => {
3808 if let ListEntry::ChannelNotes {
3809 channel_id: other_id,
3810 } = other
3811 {
3812 return channel_id == other_id;
3813 }
3814 }
3815 ListEntry::ChannelInvite(channel_1) => {
3816 if let ListEntry::ChannelInvite(channel_2) = other {
3817 return channel_1.id == channel_2.id;
3818 }
3819 }
3820 ListEntry::IncomingRequest(user_1) => {
3821 if let ListEntry::IncomingRequest(user_2) = other {
3822 return user_1.id == user_2.id;
3823 }
3824 }
3825 ListEntry::OutgoingRequest(user_1) => {
3826 if let ListEntry::OutgoingRequest(user_2) = other {
3827 return user_1.id == user_2.id;
3828 }
3829 }
3830 ListEntry::Contact {
3831 contact: contact_1, ..
3832 } => {
3833 if let ListEntry::Contact {
3834 contact: contact_2, ..
3835 } = other
3836 {
3837 return contact_1.user.id == contact_2.user.id;
3838 }
3839 }
3840 ListEntry::ChannelEditor { depth } => {
3841 if let ListEntry::ChannelEditor { depth: other_depth } = other {
3842 return depth == other_depth;
3843 }
3844 }
3845 ListEntry::ContactPlaceholder => {
3846 if let ListEntry::ContactPlaceholder = other {
3847 return true;
3848 }
3849 }
3850 }
3851 false
3852 }
3853}
3854
3855struct DraggedChannelView {
3856 channel: Channel,
3857 width: Pixels,
3858}
3859
3860impl Render for DraggedChannelView {
3861 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
3862 let ui_font = ThemeSettings::get_global(cx).ui_font.family.clone();
3863 h_flex()
3864 .font_family(ui_font)
3865 .bg(cx.theme().colors().background)
3866 .w(self.width)
3867 .p_1()
3868 .gap_1()
3869 .child(
3870 Icon::new(
3871 if self.channel.visibility == proto::ChannelVisibility::Public {
3872 IconName::Public
3873 } else {
3874 IconName::Hash
3875 },
3876 )
3877 .size(IconSize::Small)
3878 .color(Color::Muted),
3879 )
3880 .child(Label::new(self.channel.name.clone()))
3881 }
3882}
3883
3884struct JoinChannelTooltip {
3885 channel_store: Entity<ChannelStore>,
3886 channel_id: ChannelId,
3887 #[allow(unused)]
3888 has_notes_notification: bool,
3889}
3890
3891impl Render for JoinChannelTooltip {
3892 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
3893 tooltip_container(cx, |container, cx| {
3894 let participants = self
3895 .channel_store
3896 .read(cx)
3897 .channel_participants(self.channel_id);
3898
3899 container
3900 .child(Label::new("Join Channel"))
3901 .children(participants.iter().map(|participant| {
3902 h_flex()
3903 .gap_2()
3904 .child(Avatar::new(participant.avatar_uri.clone()))
3905 .child(render_participant_name_and_handle(participant))
3906 }))
3907 })
3908 }
3909}
3910
3911pub struct CollabNotificationToast {
3912 actor: Option<Arc<User>>,
3913 text: String,
3914 notification: Option<Notification>,
3915 workspace: WeakEntity<Workspace>,
3916 collab_panel: WeakEntity<CollabPanel>,
3917 focus_handle: FocusHandle,
3918}
3919
3920impl Focusable for CollabNotificationToast {
3921 fn focus_handle(&self, _cx: &App) -> FocusHandle {
3922 self.focus_handle.clone()
3923 }
3924}
3925
3926impl WorkspaceNotification for CollabNotificationToast {}
3927
3928impl CollabNotificationToast {
3929 fn focus_collab_panel(&self, window: &mut Window, cx: &mut Context<Self>) {
3930 let workspace = self.workspace.clone();
3931 window.defer(cx, move |window, cx| {
3932 workspace
3933 .update(cx, |workspace, cx| {
3934 workspace.focus_panel::<CollabPanel>(window, cx)
3935 })
3936 .ok();
3937 })
3938 }
3939
3940 fn respond(&mut self, accept: bool, window: &mut Window, cx: &mut Context<Self>) {
3941 if let Some(notification) = self.notification.take() {
3942 self.collab_panel
3943 .update(cx, |collab_panel, cx| match notification {
3944 Notification::ContactRequest { sender_id } => {
3945 collab_panel.respond_to_contact_request(sender_id, accept, window, cx);
3946 }
3947 Notification::ChannelInvitation { channel_id, .. } => {
3948 collab_panel.respond_to_channel_invite(ChannelId(channel_id), accept, cx);
3949 }
3950 Notification::ContactRequestAccepted { .. } => {}
3951 })
3952 .ok();
3953 }
3954 cx.emit(DismissEvent);
3955 }
3956}
3957
3958impl Render for CollabNotificationToast {
3959 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
3960 let needs_response = self.notification.is_some();
3961
3962 let accept_button = if needs_response {
3963 Button::new("accept", "Accept").on_click(cx.listener(|this, _, window, cx| {
3964 this.respond(true, window, cx);
3965 cx.stop_propagation();
3966 }))
3967 } else {
3968 Button::new("dismiss", "Dismiss").on_click(cx.listener(|_, _, _, cx| {
3969 cx.emit(DismissEvent);
3970 }))
3971 };
3972
3973 let decline_button = if needs_response {
3974 Button::new("decline", "Decline").on_click(cx.listener(|this, _, window, cx| {
3975 this.respond(false, window, cx);
3976 cx.stop_propagation();
3977 }))
3978 } else {
3979 Button::new("close", "Close").on_click(cx.listener(|_, _, _, cx| {
3980 cx.emit(DismissEvent);
3981 }))
3982 };
3983
3984 let avatar_uri = self
3985 .actor
3986 .as_ref()
3987 .map(|user| user.avatar_uri.clone())
3988 .unwrap_or_default();
3989
3990 div()
3991 .id("collab_notification_toast")
3992 .on_click(cx.listener(|this, _, window, cx| {
3993 this.focus_collab_panel(window, cx);
3994 cx.emit(DismissEvent);
3995 }))
3996 .child(
3997 CollabNotification::new(avatar_uri, accept_button, decline_button)
3998 .child(Label::new(self.text.clone())),
3999 )
4000 }
4001}
4002
4003impl EventEmitter<DismissEvent> for CollabNotificationToast {}
4004impl EventEmitter<SuppressEvent> for CollabNotificationToast {}
4005
4006#[cfg(any(test, feature = "test-support"))]
4007impl CollabPanel {
4008 pub fn entries_as_strings(&self) -> Vec<String> {
4009 let mut string_entries = Vec::new();
4010 for (index, entry) in self.entries.iter().enumerate() {
4011 let selected_marker = if self.selection == Some(index) {
4012 " <== selected"
4013 } else {
4014 ""
4015 };
4016 match entry {
4017 ListEntry::Header(section) => {
4018 let name = match section {
4019 Section::ActiveCall => "Active Call",
4020 Section::FavoriteChannels => "Favorites",
4021 Section::Channels => "Channels",
4022 Section::ChannelInvites => "Channel Invites",
4023 Section::ContactRequests => "Contact Requests",
4024 Section::Contacts => "Contacts",
4025 Section::Online => "Online",
4026 Section::Offline => "Offline",
4027 };
4028 string_entries.push(format!("[{name}]"));
4029 }
4030 ListEntry::Channel {
4031 channel,
4032 depth,
4033 has_children,
4034 ..
4035 } => {
4036 let indent = " ".repeat(*depth + 1);
4037 let icon = if *has_children {
4038 "v "
4039 } else if channel.visibility == proto::ChannelVisibility::Public {
4040 "🛜 "
4041 } else {
4042 "#️⃣ "
4043 };
4044 string_entries.push(format!("{indent}{icon}{}{selected_marker}", channel.name));
4045 }
4046 ListEntry::ChannelNotes { .. } => {
4047 string_entries.push(format!(" (notes){selected_marker}"));
4048 }
4049 ListEntry::ChannelEditor { depth } => {
4050 let indent = " ".repeat(*depth + 1);
4051 string_entries.push(format!("{indent}[editor]{selected_marker}"));
4052 }
4053 ListEntry::ChannelInvite(channel) => {
4054 string_entries.push(format!(" (invite) #{}{selected_marker}", channel.name));
4055 }
4056 ListEntry::CallParticipant { user, .. } => {
4057 string_entries.push(format!(" {}{selected_marker}", user.github_login));
4058 }
4059 ListEntry::ParticipantProject {
4060 worktree_root_names,
4061 ..
4062 } => {
4063 string_entries.push(format!(
4064 " {}{selected_marker}",
4065 worktree_root_names.join(", ")
4066 ));
4067 }
4068 ListEntry::ParticipantScreen { .. } => {
4069 string_entries.push(format!(" (screen){selected_marker}"));
4070 }
4071 ListEntry::IncomingRequest(user) => {
4072 string_entries.push(format!(
4073 " (incoming) {}{selected_marker}",
4074 user.github_login
4075 ));
4076 }
4077 ListEntry::OutgoingRequest(user) => {
4078 string_entries.push(format!(
4079 " (outgoing) {}{selected_marker}",
4080 user.github_login
4081 ));
4082 }
4083 ListEntry::Contact { contact, .. } => {
4084 string_entries
4085 .push(format!(" {}{selected_marker}", contact.user.github_login));
4086 }
4087 ListEntry::ContactPlaceholder => {}
4088 }
4089 }
4090 string_entries
4091 }
4092}