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