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