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