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