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