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