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