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