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