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