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