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