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, Indicator, 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 if role == proto::ChannelRole::Talker {
858 Label::new("Mic only")
859 .color(Color::Muted)
860 .into_any_element()
861 } else {
862 div().into_any_element()
863 })
864 .when_some(peer_id, |el, peer_id| {
865 if role == proto::ChannelRole::Guest {
866 return el;
867 }
868 el.tooltip(move |cx| Tooltip::text(tooltip.clone(), cx))
869 .on_click(cx.listener(move |this, _, cx| {
870 this.workspace
871 .update(cx, |workspace, cx| workspace.follow(peer_id, cx))
872 .ok();
873 }))
874 })
875 .when(is_call_admin, |el| {
876 el.on_secondary_mouse_down(cx.listener(move |this, event: &MouseDownEvent, cx| {
877 this.deploy_participant_context_menu(event.position, user_id, role, cx)
878 }))
879 })
880 }
881
882 fn render_participant_project(
883 &self,
884 project_id: u64,
885 worktree_root_names: &[String],
886 host_user_id: u64,
887 is_last: bool,
888 is_selected: bool,
889 cx: &mut ViewContext<Self>,
890 ) -> impl IntoElement {
891 let project_name: SharedString = if worktree_root_names.is_empty() {
892 "untitled".to_string()
893 } else {
894 worktree_root_names.join(", ")
895 }
896 .into();
897
898 ListItem::new(project_id as usize)
899 .selected(is_selected)
900 .on_click(cx.listener(move |this, _, cx| {
901 this.workspace
902 .update(cx, |workspace, cx| {
903 let app_state = workspace.app_state().clone();
904 workspace::join_remote_project(project_id, host_user_id, app_state, cx)
905 .detach_and_prompt_err("Failed to join project", cx, |_, _| None);
906 })
907 .ok();
908 }))
909 .start_slot(
910 h_flex()
911 .gap_1()
912 .child(render_tree_branch(is_last, false, cx))
913 .child(IconButton::new(0, IconName::Folder)),
914 )
915 .child(Label::new(project_name.clone()))
916 .tooltip(move |cx| Tooltip::text(format!("Open {}", project_name), cx))
917 }
918
919 fn render_participant_screen(
920 &self,
921 peer_id: Option<PeerId>,
922 is_last: bool,
923 is_selected: bool,
924 cx: &mut ViewContext<Self>,
925 ) -> impl IntoElement {
926 let id = peer_id.map_or(usize::MAX, |id| id.as_u64() as usize);
927
928 ListItem::new(("screen", id))
929 .selected(is_selected)
930 .start_slot(
931 h_flex()
932 .gap_1()
933 .child(render_tree_branch(is_last, false, cx))
934 .child(IconButton::new(0, IconName::Screen)),
935 )
936 .child(Label::new("Screen"))
937 .when_some(peer_id, |this, _| {
938 this.on_click(cx.listener(move |this, _, cx| {
939 this.workspace
940 .update(cx, |workspace, cx| {
941 workspace.open_shared_screen(peer_id.unwrap(), cx)
942 })
943 .ok();
944 }))
945 .tooltip(move |cx| Tooltip::text(format!("Open shared screen"), cx))
946 })
947 }
948
949 fn take_editing_state(&mut self, cx: &mut ViewContext<Self>) -> bool {
950 if let Some(_) = self.channel_editing_state.take() {
951 self.channel_name_editor.update(cx, |editor, cx| {
952 editor.set_text("", cx);
953 });
954 true
955 } else {
956 false
957 }
958 }
959
960 fn render_channel_notes(
961 &self,
962 channel_id: ChannelId,
963 is_selected: bool,
964 cx: &mut ViewContext<Self>,
965 ) -> impl IntoElement {
966 let channel_store = self.channel_store.read(cx);
967 let has_channel_buffer_changed = channel_store.has_channel_buffer_changed(channel_id);
968 ListItem::new("channel-notes")
969 .selected(is_selected)
970 .on_click(cx.listener(move |this, _, cx| {
971 this.open_channel_notes(channel_id, cx);
972 }))
973 .start_slot(
974 h_flex()
975 .relative()
976 .gap_1()
977 .child(render_tree_branch(false, true, cx))
978 .child(IconButton::new(0, IconName::File))
979 .children(has_channel_buffer_changed.then(|| {
980 div()
981 .w_1p5()
982 .z_index(1)
983 .absolute()
984 .right(px(2.))
985 .top(px(2.))
986 .child(Indicator::dot().color(Color::Info))
987 })),
988 )
989 .child(Label::new("notes"))
990 .tooltip(move |cx| Tooltip::text("Open Channel Notes", cx))
991 }
992
993 fn render_channel_chat(
994 &self,
995 channel_id: ChannelId,
996 is_selected: bool,
997 cx: &mut ViewContext<Self>,
998 ) -> impl IntoElement {
999 let channel_store = self.channel_store.read(cx);
1000 let has_messages_notification = channel_store.has_new_messages(channel_id);
1001 ListItem::new("channel-chat")
1002 .selected(is_selected)
1003 .on_click(cx.listener(move |this, _, cx| {
1004 this.join_channel_chat(channel_id, cx);
1005 }))
1006 .start_slot(
1007 h_flex()
1008 .relative()
1009 .gap_1()
1010 .child(render_tree_branch(false, false, cx))
1011 .child(IconButton::new(0, IconName::MessageBubbles))
1012 .children(has_messages_notification.then(|| {
1013 div()
1014 .w_1p5()
1015 .z_index(1)
1016 .absolute()
1017 .right(px(2.))
1018 .top(px(4.))
1019 .child(Indicator::dot().color(Color::Info))
1020 })),
1021 )
1022 .child(Label::new("chat"))
1023 .tooltip(move |cx| Tooltip::text("Open Chat", cx))
1024 }
1025
1026 fn has_subchannels(&self, ix: usize) -> bool {
1027 self.entries.get(ix).map_or(false, |entry| {
1028 if let ListEntry::Channel { has_children, .. } = entry {
1029 *has_children
1030 } else {
1031 false
1032 }
1033 })
1034 }
1035
1036 fn deploy_participant_context_menu(
1037 &mut self,
1038 position: Point<Pixels>,
1039 user_id: u64,
1040 role: proto::ChannelRole,
1041 cx: &mut ViewContext<Self>,
1042 ) {
1043 let this = cx.view().clone();
1044 if !(role == proto::ChannelRole::Guest
1045 || role == proto::ChannelRole::Talker
1046 || role == proto::ChannelRole::Member)
1047 {
1048 return;
1049 }
1050
1051 let context_menu = ContextMenu::build(cx, |mut context_menu, cx| {
1052 if role == proto::ChannelRole::Guest {
1053 context_menu = context_menu.entry(
1054 "Grant Mic Access",
1055 None,
1056 cx.handler_for(&this, move |_, cx| {
1057 ActiveCall::global(cx)
1058 .update(cx, |call, cx| {
1059 let Some(room) = call.room() else {
1060 return Task::ready(Ok(()));
1061 };
1062 room.update(cx, |room, cx| {
1063 room.set_participant_role(
1064 user_id,
1065 proto::ChannelRole::Talker,
1066 cx,
1067 )
1068 })
1069 })
1070 .detach_and_prompt_err("Failed to grant mic access", cx, |_, _| None)
1071 }),
1072 );
1073 }
1074 if role == proto::ChannelRole::Guest || role == proto::ChannelRole::Talker {
1075 context_menu = context_menu.entry(
1076 "Grant Write Access",
1077 None,
1078 cx.handler_for(&this, move |_, cx| {
1079 ActiveCall::global(cx)
1080 .update(cx, |call, cx| {
1081 let Some(room) = call.room() else {
1082 return Task::ready(Ok(()));
1083 };
1084 room.update(cx, |room, cx| {
1085 room.set_participant_role(
1086 user_id,
1087 proto::ChannelRole::Member,
1088 cx,
1089 )
1090 })
1091 })
1092 .detach_and_prompt_err("Failed to grant write access", cx, |e, _| {
1093 match e.error_code() {
1094 ErrorCode::NeedsCla => Some("This user has not yet signed the CLA at https://zed.dev/cla.".into()),
1095 _ => None,
1096 }
1097 })
1098 }),
1099 );
1100 }
1101 if role == proto::ChannelRole::Member || role == proto::ChannelRole::Talker {
1102 let label = if role == proto::ChannelRole::Talker {
1103 "Mute"
1104 } else {
1105 "Revoke Access"
1106 };
1107 context_menu = context_menu.entry(
1108 label,
1109 None,
1110 cx.handler_for(&this, move |_, cx| {
1111 ActiveCall::global(cx)
1112 .update(cx, |call, cx| {
1113 let Some(room) = call.room() else {
1114 return Task::ready(Ok(()));
1115 };
1116 room.update(cx, |room, cx| {
1117 room.set_participant_role(
1118 user_id,
1119 proto::ChannelRole::Guest,
1120 cx,
1121 )
1122 })
1123 })
1124 .detach_and_prompt_err("Failed to revoke access", cx, |_, _| None)
1125 }),
1126 );
1127 }
1128
1129 context_menu
1130 });
1131
1132 cx.focus_view(&context_menu);
1133 let subscription =
1134 cx.subscribe(&context_menu, |this, _, _: &DismissEvent, cx| {
1135 if this.context_menu.as_ref().is_some_and(|context_menu| {
1136 context_menu.0.focus_handle(cx).contains_focused(cx)
1137 }) {
1138 cx.focus_self();
1139 }
1140 this.context_menu.take();
1141 cx.notify();
1142 });
1143 self.context_menu = Some((context_menu, position, subscription));
1144 }
1145
1146 fn deploy_channel_context_menu(
1147 &mut self,
1148 position: Point<Pixels>,
1149 channel_id: ChannelId,
1150 ix: usize,
1151 cx: &mut ViewContext<Self>,
1152 ) {
1153 let clipboard_channel_name = self.channel_clipboard.as_ref().and_then(|clipboard| {
1154 self.channel_store
1155 .read(cx)
1156 .channel_for_id(clipboard.channel_id)
1157 .map(|channel| channel.name.clone())
1158 });
1159 let this = cx.view().clone();
1160
1161 let context_menu = ContextMenu::build(cx, |mut context_menu, cx| {
1162 if self.has_subchannels(ix) {
1163 let expand_action_name = if self.is_channel_collapsed(channel_id) {
1164 "Expand Subchannels"
1165 } else {
1166 "Collapse Subchannels"
1167 };
1168 context_menu = context_menu.entry(
1169 expand_action_name,
1170 None,
1171 cx.handler_for(&this, move |this, cx| {
1172 this.toggle_channel_collapsed(channel_id, cx)
1173 }),
1174 );
1175 }
1176
1177 context_menu = context_menu
1178 .entry(
1179 "Open Notes",
1180 None,
1181 cx.handler_for(&this, move |this, cx| {
1182 this.open_channel_notes(channel_id, cx)
1183 }),
1184 )
1185 .entry(
1186 "Open Chat",
1187 None,
1188 cx.handler_for(&this, move |this, cx| {
1189 this.join_channel_chat(channel_id, cx)
1190 }),
1191 )
1192 .entry(
1193 "Copy Channel Link",
1194 None,
1195 cx.handler_for(&this, move |this, cx| {
1196 this.copy_channel_link(channel_id, cx)
1197 }),
1198 );
1199
1200 let mut has_destructive_actions = false;
1201 if self.channel_store.read(cx).is_channel_admin(channel_id) {
1202 has_destructive_actions = true;
1203 context_menu = context_menu
1204 .separator()
1205 .entry(
1206 "New Subchannel",
1207 None,
1208 cx.handler_for(&this, move |this, cx| this.new_subchannel(channel_id, cx)),
1209 )
1210 .entry(
1211 "Rename",
1212 Some(Box::new(SecondaryConfirm)),
1213 cx.handler_for(&this, move |this, cx| this.rename_channel(channel_id, cx)),
1214 );
1215
1216 if let Some(channel_name) = clipboard_channel_name {
1217 context_menu = context_menu.separator().entry(
1218 format!("Move '#{}' here", channel_name),
1219 None,
1220 cx.handler_for(&this, move |this, cx| {
1221 this.move_channel_on_clipboard(channel_id, cx)
1222 }),
1223 );
1224 }
1225
1226 if self.channel_store.read(cx).is_root_channel(channel_id) {
1227 context_menu = context_menu.separator().entry(
1228 "Manage Members",
1229 None,
1230 cx.handler_for(&this, move |this, cx| this.manage_members(channel_id, cx)),
1231 )
1232 } else {
1233 context_menu = context_menu.entry(
1234 "Move this channel",
1235 None,
1236 cx.handler_for(&this, move |this, cx| {
1237 this.start_move_channel(channel_id, cx)
1238 }),
1239 );
1240 if self.channel_store.read(cx).is_public_channel(channel_id) {
1241 context_menu = context_menu.separator().entry(
1242 "Make Channel Private",
1243 None,
1244 cx.handler_for(&this, move |this, cx| {
1245 this.set_channel_visibility(
1246 channel_id,
1247 ChannelVisibility::Members,
1248 cx,
1249 )
1250 }),
1251 )
1252 } else {
1253 context_menu = context_menu.separator().entry(
1254 "Make Channel Public",
1255 None,
1256 cx.handler_for(&this, move |this, cx| {
1257 this.set_channel_visibility(
1258 channel_id,
1259 ChannelVisibility::Public,
1260 cx,
1261 )
1262 }),
1263 )
1264 }
1265 }
1266
1267 context_menu = context_menu.entry(
1268 "Delete",
1269 None,
1270 cx.handler_for(&this, move |this, cx| this.remove_channel(channel_id, cx)),
1271 );
1272 }
1273
1274 if self.channel_store.read(cx).is_root_channel(channel_id) {
1275 if !has_destructive_actions {
1276 context_menu = context_menu.separator()
1277 }
1278 context_menu = context_menu.entry(
1279 "Leave Channel",
1280 None,
1281 cx.handler_for(&this, move |this, cx| this.leave_channel(channel_id, cx)),
1282 );
1283 }
1284
1285 context_menu
1286 });
1287
1288 cx.focus_view(&context_menu);
1289 let subscription =
1290 cx.subscribe(&context_menu, |this, _, _: &DismissEvent, cx| {
1291 if this.context_menu.as_ref().is_some_and(|context_menu| {
1292 context_menu.0.focus_handle(cx).contains_focused(cx)
1293 }) {
1294 cx.focus_self();
1295 }
1296 this.context_menu.take();
1297 cx.notify();
1298 });
1299 self.context_menu = Some((context_menu, position, subscription));
1300
1301 cx.notify();
1302 }
1303
1304 fn deploy_contact_context_menu(
1305 &mut self,
1306 position: Point<Pixels>,
1307 contact: Arc<Contact>,
1308 cx: &mut ViewContext<Self>,
1309 ) {
1310 let this = cx.view().clone();
1311 let in_room = ActiveCall::global(cx).read(cx).room().is_some();
1312
1313 let context_menu = ContextMenu::build(cx, |mut context_menu, _| {
1314 let user_id = contact.user.id;
1315
1316 if contact.online && !contact.busy {
1317 let label = if in_room {
1318 format!("Invite {} to join", contact.user.github_login)
1319 } else {
1320 format!("Call {}", contact.user.github_login)
1321 };
1322 context_menu = context_menu.entry(label, None, {
1323 let this = this.clone();
1324 move |cx| {
1325 this.update(cx, |this, cx| {
1326 this.call(user_id, cx);
1327 });
1328 }
1329 });
1330 }
1331
1332 context_menu.entry("Remove Contact", None, {
1333 let this = this.clone();
1334 move |cx| {
1335 this.update(cx, |this, cx| {
1336 this.remove_contact(contact.user.id, &contact.user.github_login, cx);
1337 });
1338 }
1339 })
1340 });
1341
1342 cx.focus_view(&context_menu);
1343 let subscription =
1344 cx.subscribe(&context_menu, |this, _, _: &DismissEvent, cx| {
1345 if this.context_menu.as_ref().is_some_and(|context_menu| {
1346 context_menu.0.focus_handle(cx).contains_focused(cx)
1347 }) {
1348 cx.focus_self();
1349 }
1350 this.context_menu.take();
1351 cx.notify();
1352 });
1353 self.context_menu = Some((context_menu, position, subscription));
1354
1355 cx.notify();
1356 }
1357
1358 fn cancel(&mut self, _: &Cancel, cx: &mut ViewContext<Self>) {
1359 if self.take_editing_state(cx) {
1360 cx.focus_view(&self.filter_editor);
1361 } else {
1362 self.filter_editor.update(cx, |editor, cx| {
1363 if editor.buffer().read(cx).len(cx) > 0 {
1364 editor.set_text("", cx);
1365 }
1366 });
1367 }
1368
1369 self.update_entries(false, cx);
1370 }
1371
1372 fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
1373 let ix = self.selection.map_or(0, |ix| ix + 1);
1374 if ix < self.entries.len() {
1375 self.selection = Some(ix);
1376 }
1377
1378 if let Some(ix) = self.selection {
1379 self.scroll_to_item(ix)
1380 }
1381 cx.notify();
1382 }
1383
1384 fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
1385 let ix = self.selection.take().unwrap_or(0);
1386 if ix > 0 {
1387 self.selection = Some(ix - 1);
1388 }
1389
1390 if let Some(ix) = self.selection {
1391 self.scroll_to_item(ix)
1392 }
1393 cx.notify();
1394 }
1395
1396 fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
1397 if self.confirm_channel_edit(cx) {
1398 return;
1399 }
1400
1401 if let Some(selection) = self.selection {
1402 if let Some(entry) = self.entries.get(selection) {
1403 match entry {
1404 ListEntry::Header(section) => match section {
1405 Section::ActiveCall => Self::leave_call(cx),
1406 Section::Channels => self.new_root_channel(cx),
1407 Section::Contacts => self.toggle_contact_finder(cx),
1408 Section::ContactRequests
1409 | Section::Online
1410 | Section::Offline
1411 | Section::ChannelInvites => {
1412 self.toggle_section_expanded(*section, cx);
1413 }
1414 },
1415 ListEntry::Contact { contact, calling } => {
1416 if contact.online && !contact.busy && !calling {
1417 self.call(contact.user.id, cx);
1418 }
1419 }
1420 ListEntry::ParticipantProject {
1421 project_id,
1422 host_user_id,
1423 ..
1424 } => {
1425 if let Some(workspace) = self.workspace.upgrade() {
1426 let app_state = workspace.read(cx).app_state().clone();
1427 workspace::join_remote_project(
1428 *project_id,
1429 *host_user_id,
1430 app_state,
1431 cx,
1432 )
1433 .detach_and_prompt_err(
1434 "Failed to join project",
1435 cx,
1436 |_, _| None,
1437 );
1438 }
1439 }
1440 ListEntry::ParticipantScreen { peer_id, .. } => {
1441 let Some(peer_id) = peer_id else {
1442 return;
1443 };
1444 if let Some(workspace) = self.workspace.upgrade() {
1445 workspace.update(cx, |workspace, cx| {
1446 workspace.open_shared_screen(*peer_id, cx)
1447 });
1448 }
1449 }
1450 ListEntry::Channel { channel, .. } => {
1451 let is_active = maybe!({
1452 let call_channel = ActiveCall::global(cx)
1453 .read(cx)
1454 .room()?
1455 .read(cx)
1456 .channel_id()?;
1457
1458 Some(call_channel == channel.id)
1459 })
1460 .unwrap_or(false);
1461 if is_active {
1462 self.open_channel_notes(channel.id, cx)
1463 } else {
1464 self.join_channel(channel.id, cx)
1465 }
1466 }
1467 ListEntry::ContactPlaceholder => self.toggle_contact_finder(cx),
1468 ListEntry::CallParticipant { user, peer_id, .. } => {
1469 if Some(user) == self.user_store.read(cx).current_user().as_ref() {
1470 Self::leave_call(cx);
1471 } else if let Some(peer_id) = peer_id {
1472 self.workspace
1473 .update(cx, |workspace, cx| workspace.follow(*peer_id, cx))
1474 .ok();
1475 }
1476 }
1477 ListEntry::IncomingRequest(user) => {
1478 self.respond_to_contact_request(user.id, true, cx)
1479 }
1480 ListEntry::ChannelInvite(channel) => {
1481 self.respond_to_channel_invite(channel.id, true, cx)
1482 }
1483 ListEntry::ChannelNotes { channel_id } => {
1484 self.open_channel_notes(*channel_id, cx)
1485 }
1486 ListEntry::ChannelChat { channel_id } => {
1487 self.join_channel_chat(*channel_id, cx)
1488 }
1489
1490 ListEntry::OutgoingRequest(_) => {}
1491 ListEntry::ChannelEditor { .. } => {}
1492 }
1493 }
1494 }
1495 }
1496
1497 fn insert_space(&mut self, _: &InsertSpace, cx: &mut ViewContext<Self>) {
1498 if self.channel_editing_state.is_some() {
1499 self.channel_name_editor.update(cx, |editor, cx| {
1500 editor.insert(" ", cx);
1501 });
1502 }
1503 }
1504
1505 fn confirm_channel_edit(&mut self, cx: &mut ViewContext<CollabPanel>) -> bool {
1506 if let Some(editing_state) = &mut self.channel_editing_state {
1507 match editing_state {
1508 ChannelEditingState::Create {
1509 location,
1510 pending_name,
1511 ..
1512 } => {
1513 if pending_name.is_some() {
1514 return false;
1515 }
1516 let channel_name = self.channel_name_editor.read(cx).text(cx);
1517
1518 *pending_name = Some(channel_name.clone());
1519
1520 self.channel_store
1521 .update(cx, |channel_store, cx| {
1522 channel_store.create_channel(&channel_name, *location, cx)
1523 })
1524 .detach();
1525 cx.notify();
1526 }
1527 ChannelEditingState::Rename {
1528 location,
1529 pending_name,
1530 } => {
1531 if pending_name.is_some() {
1532 return false;
1533 }
1534 let channel_name = self.channel_name_editor.read(cx).text(cx);
1535 *pending_name = Some(channel_name.clone());
1536
1537 self.channel_store
1538 .update(cx, |channel_store, cx| {
1539 channel_store.rename(*location, &channel_name, cx)
1540 })
1541 .detach();
1542 cx.notify();
1543 }
1544 }
1545 cx.focus_self();
1546 true
1547 } else {
1548 false
1549 }
1550 }
1551
1552 fn toggle_section_expanded(&mut self, section: Section, cx: &mut ViewContext<Self>) {
1553 if let Some(ix) = self.collapsed_sections.iter().position(|s| *s == section) {
1554 self.collapsed_sections.remove(ix);
1555 } else {
1556 self.collapsed_sections.push(section);
1557 }
1558 self.update_entries(false, cx);
1559 }
1560
1561 fn collapse_selected_channel(
1562 &mut self,
1563 _: &CollapseSelectedChannel,
1564 cx: &mut ViewContext<Self>,
1565 ) {
1566 let Some(channel_id) = self.selected_channel().map(|channel| channel.id) else {
1567 return;
1568 };
1569
1570 if self.is_channel_collapsed(channel_id) {
1571 return;
1572 }
1573
1574 self.toggle_channel_collapsed(channel_id, cx);
1575 }
1576
1577 fn expand_selected_channel(&mut self, _: &ExpandSelectedChannel, cx: &mut ViewContext<Self>) {
1578 let Some(id) = self.selected_channel().map(|channel| channel.id) else {
1579 return;
1580 };
1581
1582 if !self.is_channel_collapsed(id) {
1583 return;
1584 }
1585
1586 self.toggle_channel_collapsed(id, cx)
1587 }
1588
1589 fn toggle_channel_collapsed<'a>(&mut self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
1590 match self.collapsed_channels.binary_search(&channel_id) {
1591 Ok(ix) => {
1592 self.collapsed_channels.remove(ix);
1593 }
1594 Err(ix) => {
1595 self.collapsed_channels.insert(ix, channel_id);
1596 }
1597 };
1598 self.serialize(cx);
1599 self.update_entries(true, cx);
1600 cx.notify();
1601 cx.focus_self();
1602 }
1603
1604 fn is_channel_collapsed(&self, channel_id: ChannelId) -> bool {
1605 self.collapsed_channels.binary_search(&channel_id).is_ok()
1606 }
1607
1608 fn leave_call(cx: &mut WindowContext) {
1609 ActiveCall::global(cx)
1610 .update(cx, |call, cx| call.hang_up(cx))
1611 .detach_and_prompt_err("Failed to hang up", cx, |_, _| None);
1612 }
1613
1614 fn toggle_contact_finder(&mut self, cx: &mut ViewContext<Self>) {
1615 if let Some(workspace) = self.workspace.upgrade() {
1616 workspace.update(cx, |workspace, cx| {
1617 workspace.toggle_modal(cx, |cx| {
1618 let mut finder = ContactFinder::new(self.user_store.clone(), cx);
1619 finder.set_query(self.filter_editor.read(cx).text(cx), cx);
1620 finder
1621 });
1622 });
1623 }
1624 }
1625
1626 fn new_root_channel(&mut self, cx: &mut ViewContext<Self>) {
1627 self.channel_editing_state = Some(ChannelEditingState::Create {
1628 location: None,
1629 pending_name: None,
1630 });
1631 self.update_entries(false, cx);
1632 self.select_channel_editor();
1633 cx.focus_view(&self.channel_name_editor);
1634 cx.notify();
1635 }
1636
1637 fn select_channel_editor(&mut self) {
1638 self.selection = self.entries.iter().position(|entry| match entry {
1639 ListEntry::ChannelEditor { .. } => true,
1640 _ => false,
1641 });
1642 }
1643
1644 fn new_subchannel(&mut self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
1645 self.collapsed_channels
1646 .retain(|channel| *channel != channel_id);
1647 self.channel_editing_state = Some(ChannelEditingState::Create {
1648 location: Some(channel_id),
1649 pending_name: None,
1650 });
1651 self.update_entries(false, cx);
1652 self.select_channel_editor();
1653 cx.focus_view(&self.channel_name_editor);
1654 cx.notify();
1655 }
1656
1657 fn manage_members(&mut self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
1658 self.show_channel_modal(channel_id, channel_modal::Mode::ManageMembers, cx);
1659 }
1660
1661 fn remove_selected_channel(&mut self, _: &Remove, cx: &mut ViewContext<Self>) {
1662 if let Some(channel) = self.selected_channel() {
1663 self.remove_channel(channel.id, cx)
1664 }
1665 }
1666
1667 fn rename_selected_channel(&mut self, _: &SecondaryConfirm, cx: &mut ViewContext<Self>) {
1668 if let Some(channel) = self.selected_channel() {
1669 self.rename_channel(channel.id, cx);
1670 }
1671 }
1672
1673 fn rename_channel(&mut self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
1674 let channel_store = self.channel_store.read(cx);
1675 if !channel_store.is_channel_admin(channel_id) {
1676 return;
1677 }
1678 if let Some(channel) = channel_store.channel_for_id(channel_id).cloned() {
1679 self.channel_editing_state = Some(ChannelEditingState::Rename {
1680 location: channel_id,
1681 pending_name: None,
1682 });
1683 self.channel_name_editor.update(cx, |editor, cx| {
1684 editor.set_text(channel.name.clone(), cx);
1685 editor.select_all(&Default::default(), cx);
1686 });
1687 cx.focus_view(&self.channel_name_editor);
1688 self.update_entries(false, cx);
1689 self.select_channel_editor();
1690 }
1691 }
1692
1693 fn set_channel_visibility(
1694 &mut self,
1695 channel_id: ChannelId,
1696 visibility: ChannelVisibility,
1697 cx: &mut ViewContext<Self>,
1698 ) {
1699 self.channel_store
1700 .update(cx, |channel_store, cx| {
1701 channel_store.set_channel_visibility(channel_id, visibility, cx)
1702 })
1703 .detach_and_prompt_err("Failed to set channel visibility", cx, |e, _| match e.error_code() {
1704 ErrorCode::BadPublicNesting =>
1705 if e.error_tag("direction") == Some("parent") {
1706 Some("To make a channel public, its parent channel must be public.".to_string())
1707 } else {
1708 Some("To make a channel private, all of its subchannels must be private.".to_string())
1709 },
1710 _ => None
1711 });
1712 }
1713
1714 fn start_move_channel(&mut self, channel_id: ChannelId, _cx: &mut ViewContext<Self>) {
1715 self.channel_clipboard = Some(ChannelMoveClipboard { channel_id });
1716 }
1717
1718 fn start_move_selected_channel(&mut self, _: &StartMoveChannel, cx: &mut ViewContext<Self>) {
1719 if let Some(channel) = self.selected_channel() {
1720 self.start_move_channel(channel.id, cx);
1721 }
1722 }
1723
1724 fn move_channel_on_clipboard(
1725 &mut self,
1726 to_channel_id: ChannelId,
1727 cx: &mut ViewContext<CollabPanel>,
1728 ) {
1729 if let Some(clipboard) = self.channel_clipboard.take() {
1730 self.move_channel(clipboard.channel_id, to_channel_id, cx)
1731 }
1732 }
1733
1734 fn move_channel(&self, channel_id: ChannelId, to: ChannelId, cx: &mut ViewContext<Self>) {
1735 self.channel_store
1736 .update(cx, |channel_store, cx| {
1737 channel_store.move_channel(channel_id, to, cx)
1738 })
1739 .detach_and_prompt_err("Failed to move channel", cx, |e, _| match e.error_code() {
1740 ErrorCode::BadPublicNesting => {
1741 Some("Public channels must have public parents".into())
1742 }
1743 ErrorCode::CircularNesting => Some("You cannot move a channel into itself".into()),
1744 ErrorCode::WrongMoveTarget => {
1745 Some("You cannot move a channel into a different root channel".into())
1746 }
1747 _ => None,
1748 })
1749 }
1750
1751 fn open_channel_notes(&mut self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
1752 if let Some(workspace) = self.workspace.upgrade() {
1753 ChannelView::open(channel_id, None, workspace, cx).detach();
1754 }
1755 }
1756
1757 fn show_inline_context_menu(&mut self, _: &menu::ShowContextMenu, cx: &mut ViewContext<Self>) {
1758 let Some(bounds) = self
1759 .selection
1760 .and_then(|ix| self.list_state.bounds_for_item(ix))
1761 else {
1762 return;
1763 };
1764
1765 if let Some(channel) = self.selected_channel() {
1766 self.deploy_channel_context_menu(
1767 bounds.center(),
1768 channel.id,
1769 self.selection.unwrap(),
1770 cx,
1771 );
1772 cx.stop_propagation();
1773 return;
1774 };
1775
1776 if let Some(contact) = self.selected_contact() {
1777 self.deploy_contact_context_menu(bounds.center(), contact, cx);
1778 cx.stop_propagation();
1779 return;
1780 };
1781 }
1782
1783 fn selected_channel(&self) -> Option<&Arc<Channel>> {
1784 self.selection
1785 .and_then(|ix| self.entries.get(ix))
1786 .and_then(|entry| match entry {
1787 ListEntry::Channel { channel, .. } => Some(channel),
1788 _ => None,
1789 })
1790 }
1791
1792 fn selected_contact(&self) -> Option<Arc<Contact>> {
1793 self.selection
1794 .and_then(|ix| self.entries.get(ix))
1795 .and_then(|entry| match entry {
1796 ListEntry::Contact { contact, .. } => Some(contact.clone()),
1797 _ => None,
1798 })
1799 }
1800
1801 fn show_channel_modal(
1802 &mut self,
1803 channel_id: ChannelId,
1804 mode: channel_modal::Mode,
1805 cx: &mut ViewContext<Self>,
1806 ) {
1807 let workspace = self.workspace.clone();
1808 let user_store = self.user_store.clone();
1809 let channel_store = self.channel_store.clone();
1810 let members = self.channel_store.update(cx, |channel_store, cx| {
1811 channel_store.get_channel_member_details(channel_id, cx)
1812 });
1813
1814 cx.spawn(|_, mut cx| async move {
1815 let members = members.await?;
1816 workspace.update(&mut cx, |workspace, cx| {
1817 workspace.toggle_modal(cx, |cx| {
1818 ChannelModal::new(
1819 user_store.clone(),
1820 channel_store.clone(),
1821 channel_id,
1822 mode,
1823 members,
1824 cx,
1825 )
1826 });
1827 })
1828 })
1829 .detach();
1830 }
1831
1832 fn leave_channel(&self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
1833 let Some(user_id) = self.user_store.read(cx).current_user().map(|u| u.id) else {
1834 return;
1835 };
1836 let Some(channel) = self.channel_store.read(cx).channel_for_id(channel_id) else {
1837 return;
1838 };
1839 let prompt_message = format!("Are you sure you want to leave \"#{}\"?", channel.name);
1840 let answer = cx.prompt(
1841 PromptLevel::Warning,
1842 &prompt_message,
1843 None,
1844 &["Leave", "Cancel"],
1845 );
1846 cx.spawn(|this, mut cx| async move {
1847 if answer.await? != 0 {
1848 return Ok(());
1849 }
1850 this.update(&mut cx, |this, cx| {
1851 this.channel_store.update(cx, |channel_store, cx| {
1852 channel_store.remove_member(channel_id, user_id, cx)
1853 })
1854 })?
1855 .await
1856 })
1857 .detach_and_prompt_err("Failed to leave channel", cx, |_, _| None)
1858 }
1859
1860 fn remove_channel(&mut self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
1861 let channel_store = self.channel_store.clone();
1862 if let Some(channel) = channel_store.read(cx).channel_for_id(channel_id) {
1863 let prompt_message = format!(
1864 "Are you sure you want to remove the channel \"{}\"?",
1865 channel.name
1866 );
1867 let answer = cx.prompt(
1868 PromptLevel::Warning,
1869 &prompt_message,
1870 None,
1871 &["Remove", "Cancel"],
1872 );
1873 cx.spawn(|this, mut cx| async move {
1874 if answer.await? == 0 {
1875 channel_store
1876 .update(&mut cx, |channels, _| channels.remove_channel(channel_id))?
1877 .await
1878 .notify_async_err(&mut cx);
1879 this.update(&mut cx, |_, cx| cx.focus_self()).ok();
1880 }
1881 anyhow::Ok(())
1882 })
1883 .detach();
1884 }
1885 }
1886
1887 fn remove_contact(&mut self, user_id: u64, github_login: &str, cx: &mut ViewContext<Self>) {
1888 let user_store = self.user_store.clone();
1889 let prompt_message = format!(
1890 "Are you sure you want to remove \"{}\" from your contacts?",
1891 github_login
1892 );
1893 let answer = cx.prompt(
1894 PromptLevel::Warning,
1895 &prompt_message,
1896 None,
1897 &["Remove", "Cancel"],
1898 );
1899 cx.spawn(|_, mut cx| async move {
1900 if answer.await? == 0 {
1901 user_store
1902 .update(&mut cx, |store, cx| store.remove_contact(user_id, cx))?
1903 .await
1904 .notify_async_err(&mut cx);
1905 }
1906 anyhow::Ok(())
1907 })
1908 .detach_and_prompt_err("Failed to remove contact", cx, |_, _| None);
1909 }
1910
1911 fn respond_to_contact_request(
1912 &mut self,
1913 user_id: u64,
1914 accept: bool,
1915 cx: &mut ViewContext<Self>,
1916 ) {
1917 self.user_store
1918 .update(cx, |store, cx| {
1919 store.respond_to_contact_request(user_id, accept, cx)
1920 })
1921 .detach_and_prompt_err("Failed to respond to contact request", cx, |_, _| None);
1922 }
1923
1924 fn respond_to_channel_invite(
1925 &mut self,
1926 channel_id: u64,
1927 accept: bool,
1928 cx: &mut ViewContext<Self>,
1929 ) {
1930 self.channel_store
1931 .update(cx, |store, cx| {
1932 store.respond_to_channel_invite(channel_id, accept, cx)
1933 })
1934 .detach();
1935 }
1936
1937 fn call(&mut self, recipient_user_id: u64, cx: &mut ViewContext<Self>) {
1938 ActiveCall::global(cx)
1939 .update(cx, |call, cx| {
1940 call.invite(recipient_user_id, Some(self.project.clone()), cx)
1941 })
1942 .detach_and_prompt_err("Call failed", cx, |_, _| None);
1943 }
1944
1945 fn join_channel(&self, channel_id: u64, cx: &mut ViewContext<Self>) {
1946 let Some(workspace) = self.workspace.upgrade() else {
1947 return;
1948 };
1949 let Some(handle) = cx.window_handle().downcast::<Workspace>() else {
1950 return;
1951 };
1952 workspace::join_channel(
1953 channel_id,
1954 workspace.read(cx).app_state().clone(),
1955 Some(handle),
1956 cx,
1957 )
1958 .detach_and_prompt_err("Failed to join channel", cx, |_, _| None)
1959 }
1960
1961 fn join_channel_chat(&mut self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
1962 let Some(workspace) = self.workspace.upgrade() else {
1963 return;
1964 };
1965 cx.window_context().defer(move |cx| {
1966 workspace.update(cx, |workspace, cx| {
1967 if let Some(panel) = workspace.focus_panel::<ChatPanel>(cx) {
1968 panel.update(cx, |panel, cx| {
1969 panel
1970 .select_channel(channel_id, None, cx)
1971 .detach_and_notify_err(cx);
1972 });
1973 }
1974 });
1975 });
1976 }
1977
1978 fn copy_channel_link(&mut self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
1979 let channel_store = self.channel_store.read(cx);
1980 let Some(channel) = channel_store.channel_for_id(channel_id) else {
1981 return;
1982 };
1983 let item = ClipboardItem::new(channel.link());
1984 cx.write_to_clipboard(item)
1985 }
1986
1987 fn render_signed_out(&mut self, cx: &mut ViewContext<Self>) -> Div {
1988 let collab_blurb = "Work with your team in realtime with collaborative editing, voice, shared notes and more.";
1989
1990 v_flex()
1991 .gap_6()
1992 .p_4()
1993 .child(Label::new(collab_blurb))
1994 .child(
1995 v_flex()
1996 .gap_2()
1997 .child(
1998 Button::new("sign_in", "Sign in")
1999 .icon_color(Color::Muted)
2000 .icon(IconName::Github)
2001 .icon_position(IconPosition::Start)
2002 .style(ButtonStyle::Filled)
2003 .full_width()
2004 .on_click(cx.listener(|this, _, cx| {
2005 let client = this.client.clone();
2006 cx.spawn(|_, mut cx| async move {
2007 client
2008 .authenticate_and_connect(true, &cx)
2009 .await
2010 .notify_async_err(&mut cx);
2011 })
2012 .detach()
2013 })),
2014 )
2015 .child(
2016 div().flex().w_full().items_center().child(
2017 Label::new("Sign in to enable collaboration.")
2018 .color(Color::Muted)
2019 .size(LabelSize::Small),
2020 ),
2021 ),
2022 )
2023 }
2024
2025 fn render_list_entry(&mut self, ix: usize, cx: &mut ViewContext<Self>) -> AnyElement {
2026 let entry = &self.entries[ix];
2027
2028 let is_selected = self.selection == Some(ix);
2029 match entry {
2030 ListEntry::Header(section) => {
2031 let is_collapsed = self.collapsed_sections.contains(section);
2032 self.render_header(*section, is_selected, is_collapsed, cx)
2033 .into_any_element()
2034 }
2035 ListEntry::Contact { contact, calling } => self
2036 .render_contact(contact, *calling, is_selected, cx)
2037 .into_any_element(),
2038 ListEntry::ContactPlaceholder => self
2039 .render_contact_placeholder(is_selected, cx)
2040 .into_any_element(),
2041 ListEntry::IncomingRequest(user) => self
2042 .render_contact_request(user, true, is_selected, cx)
2043 .into_any_element(),
2044 ListEntry::OutgoingRequest(user) => self
2045 .render_contact_request(user, false, is_selected, cx)
2046 .into_any_element(),
2047 ListEntry::Channel {
2048 channel,
2049 depth,
2050 has_children,
2051 } => self
2052 .render_channel(channel, *depth, *has_children, is_selected, ix, cx)
2053 .into_any_element(),
2054 ListEntry::ChannelEditor { depth } => {
2055 self.render_channel_editor(*depth, cx).into_any_element()
2056 }
2057 ListEntry::ChannelInvite(channel) => self
2058 .render_channel_invite(channel, is_selected, cx)
2059 .into_any_element(),
2060 ListEntry::CallParticipant {
2061 user,
2062 peer_id,
2063 is_pending,
2064 role,
2065 } => self
2066 .render_call_participant(user, *peer_id, *is_pending, *role, is_selected, cx)
2067 .into_any_element(),
2068 ListEntry::ParticipantProject {
2069 project_id,
2070 worktree_root_names,
2071 host_user_id,
2072 is_last,
2073 } => self
2074 .render_participant_project(
2075 *project_id,
2076 &worktree_root_names,
2077 *host_user_id,
2078 *is_last,
2079 is_selected,
2080 cx,
2081 )
2082 .into_any_element(),
2083 ListEntry::ParticipantScreen { peer_id, is_last } => self
2084 .render_participant_screen(*peer_id, *is_last, is_selected, cx)
2085 .into_any_element(),
2086 ListEntry::ChannelNotes { channel_id } => self
2087 .render_channel_notes(*channel_id, is_selected, cx)
2088 .into_any_element(),
2089 ListEntry::ChannelChat { channel_id } => self
2090 .render_channel_chat(*channel_id, is_selected, cx)
2091 .into_any_element(),
2092 }
2093 }
2094
2095 fn render_signed_in(&mut self, cx: &mut ViewContext<Self>) -> Div {
2096 v_flex()
2097 .size_full()
2098 .child(list(self.list_state.clone()).size_full())
2099 .child(
2100 v_flex()
2101 .child(div().mx_2().border_primary(cx).border_t())
2102 .child(
2103 v_flex()
2104 .p_2()
2105 .child(self.render_filter_input(&self.filter_editor, cx)),
2106 ),
2107 )
2108 }
2109
2110 fn render_filter_input(
2111 &self,
2112 editor: &View<Editor>,
2113 cx: &mut ViewContext<Self>,
2114 ) -> impl IntoElement {
2115 let settings = ThemeSettings::get_global(cx);
2116 let text_style = TextStyle {
2117 color: if editor.read(cx).read_only(cx) {
2118 cx.theme().colors().text_disabled
2119 } else {
2120 cx.theme().colors().text
2121 },
2122 font_family: settings.ui_font.family.clone(),
2123 font_features: settings.ui_font.features,
2124 font_size: rems(0.875).into(),
2125 font_weight: FontWeight::NORMAL,
2126 font_style: FontStyle::Normal,
2127 line_height: relative(1.3).into(),
2128 background_color: None,
2129 underline: None,
2130 strikethrough: None,
2131 white_space: WhiteSpace::Normal,
2132 };
2133
2134 EditorElement::new(
2135 editor,
2136 EditorStyle {
2137 local_player: cx.theme().players().local(),
2138 text: text_style,
2139 ..Default::default()
2140 },
2141 )
2142 }
2143
2144 fn render_header(
2145 &self,
2146 section: Section,
2147 is_selected: bool,
2148 is_collapsed: bool,
2149 cx: &ViewContext<Self>,
2150 ) -> impl IntoElement {
2151 let mut channel_link = None;
2152 let mut channel_tooltip_text = None;
2153 let mut channel_icon = None;
2154
2155 let text = match section {
2156 Section::ActiveCall => {
2157 let channel_name = maybe!({
2158 let channel_id = ActiveCall::global(cx).read(cx).channel_id(cx)?;
2159
2160 let channel = self.channel_store.read(cx).channel_for_id(channel_id)?;
2161
2162 channel_link = Some(channel.link());
2163 (channel_icon, channel_tooltip_text) = match channel.visibility {
2164 proto::ChannelVisibility::Public => {
2165 (Some("icons/public.svg"), Some("Copy public channel link."))
2166 }
2167 proto::ChannelVisibility::Members => {
2168 (Some("icons/hash.svg"), Some("Copy private channel link."))
2169 }
2170 };
2171
2172 Some(channel.name.as_ref())
2173 });
2174
2175 if let Some(name) = channel_name {
2176 SharedString::from(format!("{}", name))
2177 } else {
2178 SharedString::from("Current Call")
2179 }
2180 }
2181 Section::ContactRequests => SharedString::from("Requests"),
2182 Section::Contacts => SharedString::from("Contacts"),
2183 Section::Channels => SharedString::from("Channels"),
2184 Section::ChannelInvites => SharedString::from("Invites"),
2185 Section::Online => SharedString::from("Online"),
2186 Section::Offline => SharedString::from("Offline"),
2187 };
2188
2189 let button = match section {
2190 Section::ActiveCall => channel_link.map(|channel_link| {
2191 let channel_link_copy = channel_link.clone();
2192 IconButton::new("channel-link", IconName::Copy)
2193 .icon_size(IconSize::Small)
2194 .size(ButtonSize::None)
2195 .visible_on_hover("section-header")
2196 .on_click(move |_, cx| {
2197 let item = ClipboardItem::new(channel_link_copy.clone());
2198 cx.write_to_clipboard(item)
2199 })
2200 .tooltip(|cx| Tooltip::text("Copy channel link", cx))
2201 .into_any_element()
2202 }),
2203 Section::Contacts => Some(
2204 IconButton::new("add-contact", IconName::Plus)
2205 .on_click(cx.listener(|this, _, cx| this.toggle_contact_finder(cx)))
2206 .tooltip(|cx| Tooltip::text("Search for new contact", cx))
2207 .into_any_element(),
2208 ),
2209 Section::Channels => Some(
2210 IconButton::new("add-channel", IconName::Plus)
2211 .on_click(cx.listener(|this, _, cx| this.new_root_channel(cx)))
2212 .tooltip(|cx| Tooltip::text("Create a channel", cx))
2213 .into_any_element(),
2214 ),
2215 _ => None,
2216 };
2217
2218 let can_collapse = match section {
2219 Section::ActiveCall | Section::Channels | Section::Contacts => false,
2220 Section::ChannelInvites
2221 | Section::ContactRequests
2222 | Section::Online
2223 | Section::Offline => true,
2224 };
2225
2226 h_flex().w_full().group("section-header").child(
2227 ListHeader::new(text)
2228 .when(can_collapse, |header| {
2229 header
2230 .toggle(Some(!is_collapsed))
2231 .on_toggle(cx.listener(move |this, _, cx| {
2232 this.toggle_section_expanded(section, cx);
2233 }))
2234 })
2235 .inset(true)
2236 .end_slot::<AnyElement>(button)
2237 .selected(is_selected),
2238 )
2239 }
2240
2241 fn render_contact(
2242 &self,
2243 contact: &Arc<Contact>,
2244 calling: bool,
2245 is_selected: bool,
2246 cx: &mut ViewContext<Self>,
2247 ) -> impl IntoElement {
2248 let online = contact.online;
2249 let busy = contact.busy || calling;
2250 let github_login = SharedString::from(contact.user.github_login.clone());
2251 let item = ListItem::new(github_login.clone())
2252 .indent_level(1)
2253 .indent_step_size(px(20.))
2254 .selected(is_selected)
2255 .child(
2256 h_flex()
2257 .w_full()
2258 .justify_between()
2259 .child(Label::new(github_login.clone()))
2260 .when(calling, |el| {
2261 el.child(Label::new("Calling").color(Color::Muted))
2262 })
2263 .when(!calling, |el| {
2264 el.child(
2265 IconButton::new("contact context menu", IconName::Ellipsis)
2266 .icon_color(Color::Muted)
2267 .visible_on_hover("")
2268 .on_click(cx.listener({
2269 let contact = contact.clone();
2270 move |this, event: &ClickEvent, cx| {
2271 this.deploy_contact_context_menu(
2272 event.down.position,
2273 contact.clone(),
2274 cx,
2275 );
2276 }
2277 })),
2278 )
2279 }),
2280 )
2281 .on_secondary_mouse_down(cx.listener({
2282 let contact = contact.clone();
2283 move |this, event: &MouseDownEvent, cx| {
2284 this.deploy_contact_context_menu(event.position, contact.clone(), cx);
2285 }
2286 }))
2287 .start_slot(
2288 // todo handle contacts with no avatar
2289 Avatar::new(contact.user.avatar_uri.clone())
2290 .indicator::<AvatarAvailabilityIndicator>(if online {
2291 Some(AvatarAvailabilityIndicator::new(match busy {
2292 true => ui::Availability::Busy,
2293 false => ui::Availability::Free,
2294 }))
2295 } else {
2296 None
2297 }),
2298 );
2299
2300 div()
2301 .id(github_login.clone())
2302 .group("")
2303 .child(item)
2304 .tooltip(move |cx| {
2305 let text = if !online {
2306 format!(" {} is offline", &github_login)
2307 } else if busy {
2308 format!(" {} is on a call", &github_login)
2309 } else {
2310 let room = ActiveCall::global(cx).read(cx).room();
2311 if room.is_some() {
2312 format!("Invite {} to join call", &github_login)
2313 } else {
2314 format!("Call {}", &github_login)
2315 }
2316 };
2317 Tooltip::text(text, cx)
2318 })
2319 }
2320
2321 fn render_contact_request(
2322 &self,
2323 user: &Arc<User>,
2324 is_incoming: bool,
2325 is_selected: bool,
2326 cx: &mut ViewContext<Self>,
2327 ) -> impl IntoElement {
2328 let github_login = SharedString::from(user.github_login.clone());
2329 let user_id = user.id;
2330 let is_response_pending = self.user_store.read(cx).is_contact_request_pending(&user);
2331 let color = if is_response_pending {
2332 Color::Muted
2333 } else {
2334 Color::Default
2335 };
2336
2337 let controls = if is_incoming {
2338 vec![
2339 IconButton::new("decline-contact", IconName::Close)
2340 .on_click(cx.listener(move |this, _, cx| {
2341 this.respond_to_contact_request(user_id, false, cx);
2342 }))
2343 .icon_color(color)
2344 .tooltip(|cx| Tooltip::text("Decline invite", cx)),
2345 IconButton::new("accept-contact", IconName::Check)
2346 .on_click(cx.listener(move |this, _, cx| {
2347 this.respond_to_contact_request(user_id, true, cx);
2348 }))
2349 .icon_color(color)
2350 .tooltip(|cx| Tooltip::text("Accept invite", cx)),
2351 ]
2352 } else {
2353 let github_login = github_login.clone();
2354 vec![IconButton::new("remove_contact", IconName::Close)
2355 .on_click(cx.listener(move |this, _, cx| {
2356 this.remove_contact(user_id, &github_login, cx);
2357 }))
2358 .icon_color(color)
2359 .tooltip(|cx| Tooltip::text("Cancel invite", cx))]
2360 };
2361
2362 ListItem::new(github_login.clone())
2363 .indent_level(1)
2364 .indent_step_size(px(20.))
2365 .selected(is_selected)
2366 .child(
2367 h_flex()
2368 .w_full()
2369 .justify_between()
2370 .child(Label::new(github_login.clone()))
2371 .child(h_flex().children(controls)),
2372 )
2373 .start_slot(Avatar::new(user.avatar_uri.clone()))
2374 }
2375
2376 fn render_channel_invite(
2377 &self,
2378 channel: &Arc<Channel>,
2379 is_selected: bool,
2380 cx: &mut ViewContext<Self>,
2381 ) -> ListItem {
2382 let channel_id = channel.id;
2383 let response_is_pending = self
2384 .channel_store
2385 .read(cx)
2386 .has_pending_channel_invite_response(&channel);
2387 let color = if response_is_pending {
2388 Color::Muted
2389 } else {
2390 Color::Default
2391 };
2392
2393 let controls = [
2394 IconButton::new("reject-invite", IconName::Close)
2395 .on_click(cx.listener(move |this, _, cx| {
2396 this.respond_to_channel_invite(channel_id, false, cx);
2397 }))
2398 .icon_color(color)
2399 .tooltip(|cx| Tooltip::text("Decline invite", cx)),
2400 IconButton::new("accept-invite", IconName::Check)
2401 .on_click(cx.listener(move |this, _, cx| {
2402 this.respond_to_channel_invite(channel_id, true, cx);
2403 }))
2404 .icon_color(color)
2405 .tooltip(|cx| Tooltip::text("Accept invite", cx)),
2406 ];
2407
2408 ListItem::new(("channel-invite", channel.id as usize))
2409 .selected(is_selected)
2410 .child(
2411 h_flex()
2412 .w_full()
2413 .justify_between()
2414 .child(Label::new(channel.name.clone()))
2415 .child(h_flex().children(controls)),
2416 )
2417 .start_slot(
2418 Icon::new(IconName::Hash)
2419 .size(IconSize::Small)
2420 .color(Color::Muted),
2421 )
2422 }
2423
2424 fn render_contact_placeholder(
2425 &self,
2426 is_selected: bool,
2427 cx: &mut ViewContext<Self>,
2428 ) -> ListItem {
2429 ListItem::new("contact-placeholder")
2430 .child(Icon::new(IconName::Plus))
2431 .child(Label::new("Add a Contact"))
2432 .selected(is_selected)
2433 .on_click(cx.listener(|this, _, cx| this.toggle_contact_finder(cx)))
2434 }
2435
2436 fn render_channel(
2437 &self,
2438 channel: &Channel,
2439 depth: usize,
2440 has_children: bool,
2441 is_selected: bool,
2442 ix: usize,
2443 cx: &mut ViewContext<Self>,
2444 ) -> impl IntoElement {
2445 let channel_id = channel.id;
2446
2447 let is_active = maybe!({
2448 let call_channel = ActiveCall::global(cx)
2449 .read(cx)
2450 .room()?
2451 .read(cx)
2452 .channel_id()?;
2453 Some(call_channel == channel_id)
2454 })
2455 .unwrap_or(false);
2456 let channel_store = self.channel_store.read(cx);
2457 let is_public = channel_store
2458 .channel_for_id(channel_id)
2459 .map(|channel| channel.visibility)
2460 == Some(proto::ChannelVisibility::Public);
2461 let disclosed =
2462 has_children.then(|| !self.collapsed_channels.binary_search(&channel.id).is_ok());
2463
2464 let has_messages_notification = channel_store.has_new_messages(channel_id);
2465 let has_notes_notification = channel_store.has_channel_buffer_changed(channel_id);
2466
2467 const FACEPILE_LIMIT: usize = 3;
2468 let participants = self.channel_store.read(cx).channel_participants(channel_id);
2469
2470 let face_pile = if !participants.is_empty() {
2471 let extra_count = participants.len().saturating_sub(FACEPILE_LIMIT);
2472 let result = FacePile::new(
2473 participants
2474 .iter()
2475 .map(|user| Avatar::new(user.avatar_uri.clone()).into_any_element())
2476 .take(FACEPILE_LIMIT)
2477 .chain(if extra_count > 0 {
2478 Some(
2479 div()
2480 .ml_2()
2481 .child(Label::new(format!("+{extra_count}")))
2482 .into_any_element(),
2483 )
2484 } else {
2485 None
2486 })
2487 .collect::<SmallVec<_>>(),
2488 );
2489
2490 Some(result)
2491 } else {
2492 None
2493 };
2494
2495 let width = self.width.unwrap_or(px(240.));
2496 let root_id = channel.root_id();
2497
2498 div()
2499 .h_6()
2500 .id(channel_id as usize)
2501 .group("")
2502 .flex()
2503 .w_full()
2504 .when(!channel.is_root_channel(), |el| {
2505 el.on_drag(channel.clone(), move |channel, cx| {
2506 cx.new_view(|_| DraggedChannelView {
2507 channel: channel.clone(),
2508 width,
2509 })
2510 })
2511 })
2512 .drag_over::<Channel>({
2513 move |style, dragged_channel: &Channel, cx| {
2514 if dragged_channel.root_id() == root_id {
2515 style.bg(cx.theme().colors().ghost_element_hover)
2516 } else {
2517 style
2518 }
2519 }
2520 })
2521 .on_drop(cx.listener(move |this, dragged_channel: &Channel, cx| {
2522 if dragged_channel.root_id() != root_id {
2523 return;
2524 }
2525 this.move_channel(dragged_channel.id, channel_id, cx);
2526 }))
2527 .child(
2528 ListItem::new(channel_id as usize)
2529 // Add one level of depth for the disclosure arrow.
2530 .indent_level(depth + 1)
2531 .indent_step_size(px(20.))
2532 .selected(is_selected || is_active)
2533 .toggle(disclosed)
2534 .on_toggle(
2535 cx.listener(move |this, _, cx| {
2536 this.toggle_channel_collapsed(channel_id, cx)
2537 }),
2538 )
2539 .on_click(cx.listener(move |this, _, cx| {
2540 if is_active {
2541 this.open_channel_notes(channel_id, cx)
2542 } else {
2543 this.join_channel(channel_id, cx)
2544 }
2545 }))
2546 .on_secondary_mouse_down(cx.listener(
2547 move |this, event: &MouseDownEvent, cx| {
2548 this.deploy_channel_context_menu(event.position, channel_id, ix, cx)
2549 },
2550 ))
2551 .start_slot(
2552 div()
2553 .relative()
2554 .child(
2555 Icon::new(if is_public {
2556 IconName::Public
2557 } else {
2558 IconName::Hash
2559 })
2560 .size(IconSize::Small)
2561 .color(Color::Muted),
2562 )
2563 .children(has_notes_notification.then(|| {
2564 div()
2565 .w_1p5()
2566 .z_index(1)
2567 .absolute()
2568 .right(px(-1.))
2569 .top(px(-1.))
2570 .child(Indicator::dot().color(Color::Info))
2571 })),
2572 )
2573 .child(
2574 h_flex()
2575 .id(channel_id as usize)
2576 .child(Label::new(channel.name.clone()))
2577 .children(face_pile.map(|face_pile| face_pile.p_1())),
2578 ),
2579 )
2580 .child(
2581 h_flex()
2582 .absolute()
2583 .right(rems(0.))
2584 .z_index(1)
2585 .h_full()
2586 .child(
2587 h_flex()
2588 .h_full()
2589 .gap_1()
2590 .px_1()
2591 .child(
2592 IconButton::new("channel_chat", IconName::MessageBubbles)
2593 .style(ButtonStyle::Filled)
2594 .shape(ui::IconButtonShape::Square)
2595 .icon_size(IconSize::Small)
2596 .icon_color(if has_messages_notification {
2597 Color::Default
2598 } else {
2599 Color::Muted
2600 })
2601 .on_click(cx.listener(move |this, _, cx| {
2602 this.join_channel_chat(channel_id, cx)
2603 }))
2604 .tooltip(|cx| Tooltip::text("Open channel chat", cx))
2605 .visible_on_hover(""),
2606 )
2607 .child(
2608 IconButton::new("channel_notes", IconName::File)
2609 .style(ButtonStyle::Filled)
2610 .shape(ui::IconButtonShape::Square)
2611 .icon_size(IconSize::Small)
2612 .icon_color(if has_notes_notification {
2613 Color::Default
2614 } else {
2615 Color::Muted
2616 })
2617 .on_click(cx.listener(move |this, _, cx| {
2618 this.open_channel_notes(channel_id, cx)
2619 }))
2620 .tooltip(|cx| Tooltip::text("Open channel notes", cx))
2621 .visible_on_hover(""),
2622 ),
2623 ),
2624 )
2625 .tooltip({
2626 let channel_store = self.channel_store.clone();
2627 move |cx| {
2628 cx.new_view(|_| JoinChannelTooltip {
2629 channel_store: channel_store.clone(),
2630 channel_id,
2631 has_notes_notification,
2632 })
2633 .into()
2634 }
2635 })
2636 }
2637
2638 fn render_channel_editor(&self, depth: usize, _cx: &mut ViewContext<Self>) -> impl IntoElement {
2639 let item = ListItem::new("channel-editor")
2640 .inset(false)
2641 // Add one level of depth for the disclosure arrow.
2642 .indent_level(depth + 1)
2643 .indent_step_size(px(20.))
2644 .start_slot(
2645 Icon::new(IconName::Hash)
2646 .size(IconSize::Small)
2647 .color(Color::Muted),
2648 );
2649
2650 if let Some(pending_name) = self
2651 .channel_editing_state
2652 .as_ref()
2653 .and_then(|state| state.pending_name())
2654 {
2655 item.child(Label::new(pending_name))
2656 } else {
2657 item.child(self.channel_name_editor.clone())
2658 }
2659 }
2660}
2661
2662fn render_tree_branch(is_last: bool, overdraw: bool, cx: &mut WindowContext) -> impl IntoElement {
2663 let rem_size = cx.rem_size();
2664 let line_height = cx.text_style().line_height_in_pixels(rem_size);
2665 let width = rem_size * 1.5;
2666 let thickness = px(1.);
2667 let color = cx.theme().colors().text;
2668
2669 canvas(move |bounds, cx| {
2670 let start_x = (bounds.left() + bounds.right() - thickness) / 2.;
2671 let start_y = (bounds.top() + bounds.bottom() - thickness) / 2.;
2672 let right = bounds.right();
2673 let top = bounds.top();
2674
2675 cx.paint_quad(fill(
2676 Bounds::from_corners(
2677 point(start_x, top),
2678 point(
2679 start_x + thickness,
2680 if is_last {
2681 start_y
2682 } else {
2683 bounds.bottom() + if overdraw { px(1.) } else { px(0.) }
2684 },
2685 ),
2686 ),
2687 color,
2688 ));
2689 cx.paint_quad(fill(
2690 Bounds::from_corners(point(start_x, start_y), point(right, start_y + thickness)),
2691 color,
2692 ));
2693 })
2694 .w(width)
2695 .h(line_height)
2696}
2697
2698impl Render for CollabPanel {
2699 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
2700 v_flex()
2701 .key_context("CollabPanel")
2702 .on_action(cx.listener(CollabPanel::cancel))
2703 .on_action(cx.listener(CollabPanel::select_next))
2704 .on_action(cx.listener(CollabPanel::select_prev))
2705 .on_action(cx.listener(CollabPanel::confirm))
2706 .on_action(cx.listener(CollabPanel::insert_space))
2707 .on_action(cx.listener(CollabPanel::remove_selected_channel))
2708 .on_action(cx.listener(CollabPanel::show_inline_context_menu))
2709 .on_action(cx.listener(CollabPanel::rename_selected_channel))
2710 .on_action(cx.listener(CollabPanel::collapse_selected_channel))
2711 .on_action(cx.listener(CollabPanel::expand_selected_channel))
2712 .on_action(cx.listener(CollabPanel::start_move_selected_channel))
2713 .track_focus(&self.focus_handle)
2714 .size_full()
2715 .child(if self.user_store.read(cx).current_user().is_none() {
2716 self.render_signed_out(cx)
2717 } else {
2718 self.render_signed_in(cx)
2719 })
2720 .children(self.context_menu.as_ref().map(|(menu, position, _)| {
2721 overlay()
2722 .position(*position)
2723 .anchor(gpui::AnchorCorner::TopLeft)
2724 .child(menu.clone())
2725 }))
2726 }
2727}
2728
2729impl EventEmitter<PanelEvent> for CollabPanel {}
2730
2731impl Panel for CollabPanel {
2732 fn position(&self, cx: &gpui::WindowContext) -> DockPosition {
2733 CollaborationPanelSettings::get_global(cx).dock
2734 }
2735
2736 fn position_is_valid(&self, position: DockPosition) -> bool {
2737 matches!(position, DockPosition::Left | DockPosition::Right)
2738 }
2739
2740 fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
2741 settings::update_settings_file::<CollaborationPanelSettings>(
2742 self.fs.clone(),
2743 cx,
2744 move |settings| settings.dock = Some(position),
2745 );
2746 }
2747
2748 fn size(&self, cx: &gpui::WindowContext) -> Pixels {
2749 self.width
2750 .unwrap_or_else(|| CollaborationPanelSettings::get_global(cx).default_width)
2751 }
2752
2753 fn set_size(&mut self, size: Option<Pixels>, cx: &mut ViewContext<Self>) {
2754 self.width = size;
2755 self.serialize(cx);
2756 cx.notify();
2757 }
2758
2759 fn icon(&self, cx: &gpui::WindowContext) -> Option<ui::IconName> {
2760 CollaborationPanelSettings::get_global(cx)
2761 .button
2762 .then(|| ui::IconName::Collab)
2763 }
2764
2765 fn icon_tooltip(&self, _cx: &WindowContext) -> Option<&'static str> {
2766 Some("Collab Panel")
2767 }
2768
2769 fn toggle_action(&self) -> Box<dyn gpui::Action> {
2770 Box::new(ToggleFocus)
2771 }
2772
2773 fn persistent_name() -> &'static str {
2774 "CollabPanel"
2775 }
2776}
2777
2778impl FocusableView for CollabPanel {
2779 fn focus_handle(&self, cx: &AppContext) -> gpui::FocusHandle {
2780 self.filter_editor.focus_handle(cx).clone()
2781 }
2782}
2783
2784impl PartialEq for ListEntry {
2785 fn eq(&self, other: &Self) -> bool {
2786 match self {
2787 ListEntry::Header(section_1) => {
2788 if let ListEntry::Header(section_2) = other {
2789 return section_1 == section_2;
2790 }
2791 }
2792 ListEntry::CallParticipant { user: user_1, .. } => {
2793 if let ListEntry::CallParticipant { user: user_2, .. } = other {
2794 return user_1.id == user_2.id;
2795 }
2796 }
2797 ListEntry::ParticipantProject {
2798 project_id: project_id_1,
2799 ..
2800 } => {
2801 if let ListEntry::ParticipantProject {
2802 project_id: project_id_2,
2803 ..
2804 } = other
2805 {
2806 return project_id_1 == project_id_2;
2807 }
2808 }
2809 ListEntry::ParticipantScreen {
2810 peer_id: peer_id_1, ..
2811 } => {
2812 if let ListEntry::ParticipantScreen {
2813 peer_id: peer_id_2, ..
2814 } = other
2815 {
2816 return peer_id_1 == peer_id_2;
2817 }
2818 }
2819 ListEntry::Channel {
2820 channel: channel_1, ..
2821 } => {
2822 if let ListEntry::Channel {
2823 channel: channel_2, ..
2824 } = other
2825 {
2826 return channel_1.id == channel_2.id;
2827 }
2828 }
2829 ListEntry::ChannelNotes { channel_id } => {
2830 if let ListEntry::ChannelNotes {
2831 channel_id: other_id,
2832 } = other
2833 {
2834 return channel_id == other_id;
2835 }
2836 }
2837 ListEntry::ChannelChat { channel_id } => {
2838 if let ListEntry::ChannelChat {
2839 channel_id: other_id,
2840 } = other
2841 {
2842 return channel_id == other_id;
2843 }
2844 }
2845 ListEntry::ChannelInvite(channel_1) => {
2846 if let ListEntry::ChannelInvite(channel_2) = other {
2847 return channel_1.id == channel_2.id;
2848 }
2849 }
2850 ListEntry::IncomingRequest(user_1) => {
2851 if let ListEntry::IncomingRequest(user_2) = other {
2852 return user_1.id == user_2.id;
2853 }
2854 }
2855 ListEntry::OutgoingRequest(user_1) => {
2856 if let ListEntry::OutgoingRequest(user_2) = other {
2857 return user_1.id == user_2.id;
2858 }
2859 }
2860 ListEntry::Contact {
2861 contact: contact_1, ..
2862 } => {
2863 if let ListEntry::Contact {
2864 contact: contact_2, ..
2865 } = other
2866 {
2867 return contact_1.user.id == contact_2.user.id;
2868 }
2869 }
2870 ListEntry::ChannelEditor { depth } => {
2871 if let ListEntry::ChannelEditor { depth: other_depth } = other {
2872 return depth == other_depth;
2873 }
2874 }
2875 ListEntry::ContactPlaceholder => {
2876 if let ListEntry::ContactPlaceholder = other {
2877 return true;
2878 }
2879 }
2880 }
2881 false
2882 }
2883}
2884
2885struct DraggedChannelView {
2886 channel: Channel,
2887 width: Pixels,
2888}
2889
2890impl Render for DraggedChannelView {
2891 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl Element {
2892 let ui_font = ThemeSettings::get_global(cx).ui_font.family.clone();
2893 h_flex()
2894 .font(ui_font)
2895 .bg(cx.theme().colors().background)
2896 .w(self.width)
2897 .p_1()
2898 .gap_1()
2899 .child(
2900 Icon::new(
2901 if self.channel.visibility == proto::ChannelVisibility::Public {
2902 IconName::Public
2903 } else {
2904 IconName::Hash
2905 },
2906 )
2907 .size(IconSize::Small)
2908 .color(Color::Muted),
2909 )
2910 .child(Label::new(self.channel.name.clone()))
2911 }
2912}
2913
2914struct JoinChannelTooltip {
2915 channel_store: Model<ChannelStore>,
2916 channel_id: ChannelId,
2917 has_notes_notification: bool,
2918}
2919
2920impl Render for JoinChannelTooltip {
2921 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
2922 tooltip_container(cx, |container, cx| {
2923 let participants = self
2924 .channel_store
2925 .read(cx)
2926 .channel_participants(self.channel_id);
2927
2928 container
2929 .child(Label::new("Join channel"))
2930 .children(self.has_notes_notification.then(|| {
2931 h_flex()
2932 .gap_2()
2933 .child(Indicator::dot().color(Color::Info))
2934 .child(Label::new("Unread notes"))
2935 }))
2936 .children(participants.iter().map(|participant| {
2937 h_flex()
2938 .gap_2()
2939 .child(Avatar::new(participant.avatar_uri.clone()))
2940 .child(Label::new(participant.github_login.clone()))
2941 }))
2942 })
2943 }
2944}