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