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