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