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