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