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