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