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