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