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