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