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