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