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