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