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