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, serde_json, AnyElement,
19 AppContext, AsyncWindowContext, Bounds, ClipboardItem, DismissEvent, Div, EventEmitter,
20 FocusHandle, FocusableView, FontStyle, FontWeight, InteractiveElement, IntoElement, ListOffset,
21 ListState, Model, MouseDownEvent, ParentElement, Pixels, Point, PromptLevel, Render,
22 RenderOnce, SharedString, Styled, Subscription, Task, TextStyle, View, ViewContext,
23 VisualContext, 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, SettingsStore};
30use smallvec::SmallVec;
31use std::{mem, sync::Arc};
32use theme::{ActiveTheme, ThemeSettings};
33use ui::{
34 prelude::*, Avatar, Button, Color, ContextMenu, Icon, IconButton, IconElement, IconSize, Label,
35 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.new_view(|cx| {
181 let filter_editor = cx.new_view(|cx| {
182 let mut editor = Editor::single_line(cx);
183 editor.set_placeholder_text("Filter...", cx);
184 editor
185 });
186
187 cx.subscribe(&filter_editor, |this: &mut Self, _, event, cx| {
188 if let editor::EditorEvent::BufferEdited = event {
189 let query = this.filter_editor.read(cx).text(cx);
190 if !query.is_empty() {
191 this.selection.take();
192 }
193 this.update_entries(true, cx);
194 if !query.is_empty() {
195 this.selection = this
196 .entries
197 .iter()
198 .position(|entry| !matches!(entry, ListEntry::Header(_)));
199 }
200 }
201 })
202 .detach();
203
204 let channel_name_editor = cx.new_view(|cx| Editor::single_line(cx));
205
206 cx.subscribe(&channel_name_editor, |this: &mut Self, _, event, cx| {
207 if let editor::EditorEvent::Blurred = event {
208 if let Some(state) = &this.channel_editing_state {
209 if state.pending_name().is_some() {
210 return;
211 }
212 }
213 this.take_editing_state(cx);
214 this.update_entries(false, cx);
215 cx.notify();
216 }
217 })
218 .detach();
219
220 let view = cx.view().downgrade();
221 let list_state =
222 ListState::new(0, gpui::ListAlignment::Top, px(1000.), move |ix, cx| {
223 if let Some(view) = view.upgrade() {
224 view.update(cx, |view, cx| view.render_list_entry(ix, cx))
225 } else {
226 div().into_any()
227 }
228 });
229
230 let mut this = Self {
231 width: None,
232 focus_handle: cx.focus_handle(),
233 channel_clipboard: None,
234 fs: workspace.app_state().fs.clone(),
235 pending_serialization: Task::ready(None),
236 context_menu: None,
237 list_state,
238 channel_name_editor,
239 filter_editor,
240 entries: Vec::default(),
241 channel_editing_state: None,
242 selection: None,
243 channel_store: ChannelStore::global(cx),
244 user_store: workspace.user_store().clone(),
245 project: workspace.project().clone(),
246 subscriptions: Vec::default(),
247 match_candidates: Vec::default(),
248 collapsed_sections: vec![Section::Offline],
249 collapsed_channels: Vec::default(),
250 workspace: workspace.weak_handle(),
251 client: workspace.app_state().client.clone(),
252 };
253
254 this.update_entries(false, cx);
255
256 // 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 None,
997 cx.handler_for(&this, move |this, cx| {
998 this.toggle_channel_collapsed(channel_id, cx)
999 }),
1000 );
1001 }
1002
1003 context_menu = context_menu
1004 .entry(
1005 "Open Notes",
1006 None,
1007 cx.handler_for(&this, move |this, cx| {
1008 this.open_channel_notes(channel_id, cx)
1009 }),
1010 )
1011 .entry(
1012 "Open Chat",
1013 None,
1014 cx.handler_for(&this, move |this, cx| {
1015 this.join_channel_chat(channel_id, cx)
1016 }),
1017 )
1018 .entry(
1019 "Copy Channel Link",
1020 None,
1021 cx.handler_for(&this, move |this, cx| {
1022 this.copy_channel_link(channel_id, cx)
1023 }),
1024 );
1025
1026 if self.channel_store.read(cx).is_channel_admin(channel_id) {
1027 context_menu = context_menu
1028 .separator()
1029 .entry(
1030 "New Subchannel",
1031 None,
1032 cx.handler_for(&this, move |this, cx| this.new_subchannel(channel_id, cx)),
1033 )
1034 .entry(
1035 "Rename",
1036 None,
1037 cx.handler_for(&this, move |this, cx| this.rename_channel(channel_id, cx)),
1038 )
1039 .entry(
1040 "Move this channel",
1041 None,
1042 cx.handler_for(&this, move |this, cx| {
1043 this.start_move_channel(channel_id, cx)
1044 }),
1045 );
1046
1047 if let Some(channel_name) = clipboard_channel_name {
1048 context_menu = context_menu.separator().entry(
1049 format!("Move '#{}' here", channel_name),
1050 None,
1051 cx.handler_for(&this, move |this, cx| {
1052 this.move_channel_on_clipboard(channel_id, cx)
1053 }),
1054 );
1055 }
1056
1057 context_menu = context_menu
1058 .separator()
1059 .entry(
1060 "Invite Members",
1061 None,
1062 cx.handler_for(&this, move |this, cx| this.invite_members(channel_id, cx)),
1063 )
1064 .entry(
1065 "Manage Members",
1066 None,
1067 cx.handler_for(&this, move |this, cx| this.manage_members(channel_id, cx)),
1068 )
1069 .entry(
1070 "Delete",
1071 None,
1072 cx.handler_for(&this, move |this, cx| this.remove_channel(channel_id, cx)),
1073 );
1074 }
1075
1076 context_menu
1077 });
1078
1079 cx.focus_view(&context_menu);
1080 let subscription =
1081 cx.subscribe(&context_menu, |this, _, _: &DismissEvent, cx| {
1082 if this.context_menu.as_ref().is_some_and(|context_menu| {
1083 context_menu.0.focus_handle(cx).contains_focused(cx)
1084 }) {
1085 cx.focus_self();
1086 }
1087 this.context_menu.take();
1088 cx.notify();
1089 });
1090 self.context_menu = Some((context_menu, position, subscription));
1091
1092 cx.notify();
1093 }
1094
1095 fn cancel(&mut self, _: &Cancel, cx: &mut ViewContext<Self>) {
1096 if self.take_editing_state(cx) {
1097 cx.focus_view(&self.filter_editor);
1098 } else {
1099 self.filter_editor.update(cx, |editor, cx| {
1100 if editor.buffer().read(cx).len(cx) > 0 {
1101 editor.set_text("", cx);
1102 }
1103 });
1104 }
1105
1106 self.update_entries(false, cx);
1107 }
1108
1109 fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
1110 let ix = self.selection.map_or(0, |ix| ix + 1);
1111 if ix < self.entries.len() {
1112 self.selection = Some(ix);
1113 }
1114
1115 if let Some(ix) = self.selection {
1116 self.scroll_to_item(ix)
1117 }
1118 cx.notify();
1119 }
1120
1121 fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
1122 let ix = self.selection.take().unwrap_or(0);
1123 if ix > 0 {
1124 self.selection = Some(ix - 1);
1125 }
1126
1127 if let Some(ix) = self.selection {
1128 self.scroll_to_item(ix)
1129 }
1130 cx.notify();
1131 }
1132
1133 fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
1134 if self.confirm_channel_edit(cx) {
1135 return;
1136 }
1137
1138 if let Some(selection) = self.selection {
1139 if let Some(entry) = self.entries.get(selection) {
1140 match entry {
1141 ListEntry::Header(section) => match section {
1142 Section::ActiveCall => Self::leave_call(cx),
1143 Section::Channels => self.new_root_channel(cx),
1144 Section::Contacts => self.toggle_contact_finder(cx),
1145 Section::ContactRequests
1146 | Section::Online
1147 | Section::Offline
1148 | Section::ChannelInvites => {
1149 self.toggle_section_expanded(*section, cx);
1150 }
1151 },
1152 ListEntry::Contact { contact, calling } => {
1153 if contact.online && !contact.busy && !calling {
1154 self.call(contact.user.id, cx);
1155 }
1156 }
1157 ListEntry::ParticipantProject {
1158 project_id,
1159 host_user_id,
1160 ..
1161 } => {
1162 if let Some(workspace) = self.workspace.upgrade() {
1163 let app_state = workspace.read(cx).app_state().clone();
1164 workspace::join_remote_project(
1165 *project_id,
1166 *host_user_id,
1167 app_state,
1168 cx,
1169 )
1170 .detach_and_log_err(cx);
1171 }
1172 }
1173 ListEntry::ParticipantScreen { peer_id, .. } => {
1174 let Some(peer_id) = peer_id else {
1175 return;
1176 };
1177 if let Some(workspace) = self.workspace.upgrade() {
1178 workspace.update(cx, |workspace, cx| {
1179 workspace.open_shared_screen(*peer_id, cx)
1180 });
1181 }
1182 }
1183 ListEntry::Channel { channel, .. } => {
1184 let is_active = maybe!({
1185 let call_channel = ActiveCall::global(cx)
1186 .read(cx)
1187 .room()?
1188 .read(cx)
1189 .channel_id()?;
1190
1191 Some(call_channel == channel.id)
1192 })
1193 .unwrap_or(false);
1194 if is_active {
1195 self.open_channel_notes(channel.id, cx)
1196 } else {
1197 self.join_channel(channel.id, cx)
1198 }
1199 }
1200 ListEntry::ContactPlaceholder => self.toggle_contact_finder(cx),
1201 ListEntry::CallParticipant { user, peer_id, .. } => {
1202 if Some(user) == self.user_store.read(cx).current_user().as_ref() {
1203 Self::leave_call(cx);
1204 } else if let Some(peer_id) = peer_id {
1205 self.workspace
1206 .update(cx, |workspace, cx| workspace.follow(*peer_id, cx))
1207 .ok();
1208 }
1209 }
1210 ListEntry::IncomingRequest(user) => {
1211 self.respond_to_contact_request(user.id, true, cx)
1212 }
1213 ListEntry::ChannelInvite(channel) => {
1214 self.respond_to_channel_invite(channel.id, true, cx)
1215 }
1216 ListEntry::ChannelNotes { channel_id } => {
1217 self.open_channel_notes(*channel_id, cx)
1218 }
1219 ListEntry::ChannelChat { channel_id } => {
1220 self.join_channel_chat(*channel_id, cx)
1221 }
1222
1223 ListEntry::OutgoingRequest(_) => {}
1224 ListEntry::ChannelEditor { .. } => {}
1225 }
1226 }
1227 }
1228 }
1229
1230 fn insert_space(&mut self, _: &InsertSpace, cx: &mut ViewContext<Self>) {
1231 if self.channel_editing_state.is_some() {
1232 self.channel_name_editor.update(cx, |editor, cx| {
1233 editor.insert(" ", cx);
1234 });
1235 }
1236 }
1237
1238 fn confirm_channel_edit(&mut self, cx: &mut ViewContext<CollabPanel>) -> bool {
1239 if let Some(editing_state) = &mut self.channel_editing_state {
1240 match editing_state {
1241 ChannelEditingState::Create {
1242 location,
1243 pending_name,
1244 ..
1245 } => {
1246 if pending_name.is_some() {
1247 return false;
1248 }
1249 let channel_name = self.channel_name_editor.read(cx).text(cx);
1250
1251 *pending_name = Some(channel_name.clone());
1252
1253 self.channel_store
1254 .update(cx, |channel_store, cx| {
1255 channel_store.create_channel(&channel_name, *location, cx)
1256 })
1257 .detach();
1258 cx.notify();
1259 }
1260 ChannelEditingState::Rename {
1261 location,
1262 pending_name,
1263 } => {
1264 if pending_name.is_some() {
1265 return false;
1266 }
1267 let channel_name = self.channel_name_editor.read(cx).text(cx);
1268 *pending_name = Some(channel_name.clone());
1269
1270 self.channel_store
1271 .update(cx, |channel_store, cx| {
1272 channel_store.rename(*location, &channel_name, cx)
1273 })
1274 .detach();
1275 cx.notify();
1276 }
1277 }
1278 cx.focus_self();
1279 true
1280 } else {
1281 false
1282 }
1283 }
1284
1285 fn toggle_section_expanded(&mut self, section: Section, cx: &mut ViewContext<Self>) {
1286 if let Some(ix) = self.collapsed_sections.iter().position(|s| *s == section) {
1287 self.collapsed_sections.remove(ix);
1288 } else {
1289 self.collapsed_sections.push(section);
1290 }
1291 self.update_entries(false, cx);
1292 }
1293
1294 fn collapse_selected_channel(
1295 &mut self,
1296 _: &CollapseSelectedChannel,
1297 cx: &mut ViewContext<Self>,
1298 ) {
1299 let Some(channel_id) = self.selected_channel().map(|channel| channel.id) else {
1300 return;
1301 };
1302
1303 if self.is_channel_collapsed(channel_id) {
1304 return;
1305 }
1306
1307 self.toggle_channel_collapsed(channel_id, cx);
1308 }
1309
1310 fn expand_selected_channel(&mut self, _: &ExpandSelectedChannel, cx: &mut ViewContext<Self>) {
1311 let Some(id) = self.selected_channel().map(|channel| channel.id) else {
1312 return;
1313 };
1314
1315 if !self.is_channel_collapsed(id) {
1316 return;
1317 }
1318
1319 self.toggle_channel_collapsed(id, cx)
1320 }
1321
1322 // fn toggle_channel_collapsed_action(
1323 // &mut self,
1324 // action: &ToggleCollapse,
1325 // cx: &mut ViewContext<Self>,
1326 // ) {
1327 // self.toggle_channel_collapsed(action.location, cx);
1328 // }
1329
1330 fn toggle_channel_collapsed<'a>(&mut self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
1331 match self.collapsed_channels.binary_search(&channel_id) {
1332 Ok(ix) => {
1333 self.collapsed_channels.remove(ix);
1334 }
1335 Err(ix) => {
1336 self.collapsed_channels.insert(ix, channel_id);
1337 }
1338 };
1339 self.serialize(cx);
1340 self.update_entries(true, cx);
1341 cx.notify();
1342 cx.focus_self();
1343 }
1344
1345 fn is_channel_collapsed(&self, channel_id: ChannelId) -> bool {
1346 self.collapsed_channels.binary_search(&channel_id).is_ok()
1347 }
1348
1349 fn leave_call(cx: &mut WindowContext) {
1350 ActiveCall::global(cx)
1351 .update(cx, |call, cx| call.hang_up(cx))
1352 .detach_and_log_err(cx);
1353 }
1354
1355 fn toggle_contact_finder(&mut self, cx: &mut ViewContext<Self>) {
1356 if let Some(workspace) = self.workspace.upgrade() {
1357 workspace.update(cx, |workspace, cx| {
1358 workspace.toggle_modal(cx, |cx| {
1359 let mut finder = ContactFinder::new(self.user_store.clone(), cx);
1360 finder.set_query(self.filter_editor.read(cx).text(cx), cx);
1361 finder
1362 });
1363 });
1364 }
1365 }
1366
1367 fn new_root_channel(&mut self, cx: &mut ViewContext<Self>) {
1368 self.channel_editing_state = Some(ChannelEditingState::Create {
1369 location: None,
1370 pending_name: None,
1371 });
1372 self.update_entries(false, cx);
1373 self.select_channel_editor();
1374 cx.focus_view(&self.channel_name_editor);
1375 cx.notify();
1376 }
1377
1378 fn select_channel_editor(&mut self) {
1379 self.selection = self.entries.iter().position(|entry| match entry {
1380 ListEntry::ChannelEditor { .. } => true,
1381 _ => false,
1382 });
1383 }
1384
1385 fn new_subchannel(&mut self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
1386 self.collapsed_channels
1387 .retain(|channel| *channel != channel_id);
1388 self.channel_editing_state = Some(ChannelEditingState::Create {
1389 location: Some(channel_id),
1390 pending_name: None,
1391 });
1392 self.update_entries(false, cx);
1393 self.select_channel_editor();
1394 cx.focus_view(&self.channel_name_editor);
1395 cx.notify();
1396 }
1397
1398 fn invite_members(&mut self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
1399 self.show_channel_modal(channel_id, channel_modal::Mode::InviteMembers, cx);
1400 }
1401
1402 fn manage_members(&mut self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
1403 self.show_channel_modal(channel_id, channel_modal::Mode::ManageMembers, cx);
1404 }
1405
1406 fn remove_selected_channel(&mut self, _: &Remove, cx: &mut ViewContext<Self>) {
1407 if let Some(channel) = self.selected_channel() {
1408 self.remove_channel(channel.id, cx)
1409 }
1410 }
1411
1412 fn rename_selected_channel(&mut self, _: &menu::SecondaryConfirm, cx: &mut ViewContext<Self>) {
1413 if let Some(channel) = self.selected_channel() {
1414 self.rename_channel(channel.id, cx);
1415 }
1416 }
1417
1418 fn rename_channel(&mut self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
1419 let channel_store = self.channel_store.read(cx);
1420 if !channel_store.is_channel_admin(channel_id) {
1421 return;
1422 }
1423 if let Some(channel) = channel_store.channel_for_id(channel_id).cloned() {
1424 self.channel_editing_state = Some(ChannelEditingState::Rename {
1425 location: channel_id,
1426 pending_name: None,
1427 });
1428 self.channel_name_editor.update(cx, |editor, cx| {
1429 editor.set_text(channel.name.clone(), cx);
1430 editor.select_all(&Default::default(), cx);
1431 });
1432 cx.focus_view(&self.channel_name_editor);
1433 self.update_entries(false, cx);
1434 self.select_channel_editor();
1435 }
1436 }
1437
1438 fn start_move_channel(&mut self, channel_id: ChannelId, _cx: &mut ViewContext<Self>) {
1439 self.channel_clipboard = Some(ChannelMoveClipboard { channel_id });
1440 }
1441
1442 fn start_move_selected_channel(&mut self, _: &StartMoveChannel, cx: &mut ViewContext<Self>) {
1443 if let Some(channel) = self.selected_channel() {
1444 self.start_move_channel(channel.id, cx);
1445 }
1446 }
1447
1448 fn move_channel_on_clipboard(
1449 &mut self,
1450 to_channel_id: ChannelId,
1451 cx: &mut ViewContext<CollabPanel>,
1452 ) {
1453 if let Some(clipboard) = self.channel_clipboard.take() {
1454 self.channel_store.update(cx, |channel_store, cx| {
1455 channel_store
1456 .move_channel(clipboard.channel_id, Some(to_channel_id), cx)
1457 .detach_and_log_err(cx)
1458 })
1459 }
1460 }
1461
1462 fn open_channel_notes(&mut self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
1463 if let Some(workspace) = self.workspace.upgrade() {
1464 ChannelView::open(channel_id, workspace, cx).detach();
1465 }
1466 }
1467
1468 fn show_inline_context_menu(&mut self, _: &menu::ShowContextMenu, cx: &mut ViewContext<Self>) {
1469 let Some(channel) = self.selected_channel() else {
1470 return;
1471 };
1472 let Some(bounds) = self
1473 .selection
1474 .and_then(|ix| self.list_state.bounds_for_item(ix))
1475 else {
1476 return;
1477 };
1478
1479 self.deploy_channel_context_menu(bounds.center(), channel.id, self.selection.unwrap(), cx);
1480 cx.stop_propagation();
1481 }
1482
1483 fn selected_channel(&self) -> Option<&Arc<Channel>> {
1484 self.selection
1485 .and_then(|ix| self.entries.get(ix))
1486 .and_then(|entry| match entry {
1487 ListEntry::Channel { channel, .. } => Some(channel),
1488 _ => None,
1489 })
1490 }
1491
1492 fn show_channel_modal(
1493 &mut self,
1494 channel_id: ChannelId,
1495 mode: channel_modal::Mode,
1496 cx: &mut ViewContext<Self>,
1497 ) {
1498 let workspace = self.workspace.clone();
1499 let user_store = self.user_store.clone();
1500 let channel_store = self.channel_store.clone();
1501 let members = self.channel_store.update(cx, |channel_store, cx| {
1502 channel_store.get_channel_member_details(channel_id, cx)
1503 });
1504
1505 cx.spawn(|_, mut cx| async move {
1506 let members = members.await?;
1507 workspace.update(&mut cx, |workspace, cx| {
1508 workspace.toggle_modal(cx, |cx| {
1509 ChannelModal::new(
1510 user_store.clone(),
1511 channel_store.clone(),
1512 channel_id,
1513 mode,
1514 members,
1515 cx,
1516 )
1517 });
1518 })
1519 })
1520 .detach();
1521 }
1522
1523 fn remove_channel(&mut self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
1524 let channel_store = self.channel_store.clone();
1525 if let Some(channel) = channel_store.read(cx).channel_for_id(channel_id) {
1526 let prompt_message = format!(
1527 "Are you sure you want to remove the channel \"{}\"?",
1528 channel.name
1529 );
1530 let answer = cx.prompt(PromptLevel::Warning, &prompt_message, &["Remove", "Cancel"]);
1531 cx.spawn(|this, mut cx| async move {
1532 if answer.await? == 0 {
1533 channel_store
1534 .update(&mut cx, |channels, _| channels.remove_channel(channel_id))?
1535 .await
1536 .notify_async_err(&mut cx);
1537 this.update(&mut cx, |_, cx| cx.focus_self()).ok();
1538 }
1539 anyhow::Ok(())
1540 })
1541 .detach();
1542 }
1543 }
1544
1545 fn remove_contact(&mut self, user_id: u64, github_login: &str, cx: &mut ViewContext<Self>) {
1546 let user_store = self.user_store.clone();
1547 let prompt_message = format!(
1548 "Are you sure you want to remove \"{}\" from your contacts?",
1549 github_login
1550 );
1551 let answer = cx.prompt(PromptLevel::Warning, &prompt_message, &["Remove", "Cancel"]);
1552 cx.spawn(|_, mut cx| async move {
1553 if answer.await? == 0 {
1554 user_store
1555 .update(&mut cx, |store, cx| store.remove_contact(user_id, cx))?
1556 .await
1557 .notify_async_err(&mut cx);
1558 }
1559 anyhow::Ok(())
1560 })
1561 .detach_and_log_err(cx);
1562 }
1563
1564 fn respond_to_contact_request(
1565 &mut self,
1566 user_id: u64,
1567 accept: bool,
1568 cx: &mut ViewContext<Self>,
1569 ) {
1570 self.user_store
1571 .update(cx, |store, cx| {
1572 store.respond_to_contact_request(user_id, accept, cx)
1573 })
1574 .detach_and_log_err(cx);
1575 }
1576
1577 fn respond_to_channel_invite(
1578 &mut self,
1579 channel_id: u64,
1580 accept: bool,
1581 cx: &mut ViewContext<Self>,
1582 ) {
1583 self.channel_store
1584 .update(cx, |store, cx| {
1585 store.respond_to_channel_invite(channel_id, accept, cx)
1586 })
1587 .detach();
1588 }
1589
1590 fn call(&mut self, recipient_user_id: u64, cx: &mut ViewContext<Self>) {
1591 ActiveCall::global(cx)
1592 .update(cx, |call, cx| {
1593 call.invite(recipient_user_id, Some(self.project.clone()), cx)
1594 })
1595 .detach_and_log_err(cx);
1596 }
1597
1598 fn join_channel(&self, channel_id: u64, cx: &mut ViewContext<Self>) {
1599 let Some(workspace) = self.workspace.upgrade() else {
1600 return;
1601 };
1602 let Some(handle) = cx.window_handle().downcast::<Workspace>() else {
1603 return;
1604 };
1605 workspace::join_channel(
1606 channel_id,
1607 workspace.read(cx).app_state().clone(),
1608 Some(handle),
1609 cx,
1610 )
1611 .detach_and_log_err(cx)
1612 }
1613
1614 fn join_channel_chat(&mut self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
1615 let Some(workspace) = self.workspace.upgrade() else {
1616 return;
1617 };
1618 cx.window_context().defer(move |cx| {
1619 workspace.update(cx, |workspace, cx| {
1620 if let Some(panel) = workspace.focus_panel::<ChatPanel>(cx) {
1621 panel.update(cx, |panel, cx| {
1622 panel
1623 .select_channel(channel_id, None, cx)
1624 .detach_and_log_err(cx);
1625 });
1626 }
1627 });
1628 });
1629 }
1630
1631 fn copy_channel_link(&mut self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
1632 let channel_store = self.channel_store.read(cx);
1633 let Some(channel) = channel_store.channel_for_id(channel_id) else {
1634 return;
1635 };
1636 let item = ClipboardItem::new(channel.link());
1637 cx.write_to_clipboard(item)
1638 }
1639
1640 fn render_signed_out(&mut self, cx: &mut ViewContext<Self>) -> Div {
1641 let collab_blurb = "Work with your team in realtime with collaborative editing, voice, shared notes and more.";
1642
1643 v_stack()
1644 .gap_6()
1645 .p_4()
1646 .child(Label::new(collab_blurb))
1647 .child(
1648 v_stack()
1649 .gap_2()
1650 .child(
1651 Button::new("sign_in", "Sign in")
1652 .icon_color(Color::Muted)
1653 .icon(Icon::Github)
1654 .icon_position(IconPosition::Start)
1655 .style(ButtonStyle::Filled)
1656 .full_width()
1657 .on_click(cx.listener(|this, _, cx| {
1658 let client = this.client.clone();
1659 cx.spawn(|_, mut cx| async move {
1660 client
1661 .authenticate_and_connect(true, &cx)
1662 .await
1663 .notify_async_err(&mut cx);
1664 })
1665 .detach()
1666 })),
1667 )
1668 .child(
1669 div().flex().w_full().items_center().child(
1670 Label::new("Sign in to enable collaboration.")
1671 .color(Color::Muted)
1672 .size(LabelSize::Small),
1673 ),
1674 ),
1675 )
1676 }
1677
1678 fn render_list_entry(&mut self, ix: usize, cx: &mut ViewContext<Self>) -> AnyElement {
1679 let entry = &self.entries[ix];
1680
1681 let is_selected = self.selection == Some(ix);
1682 match entry {
1683 ListEntry::Header(section) => {
1684 let is_collapsed = self.collapsed_sections.contains(section);
1685 self.render_header(*section, is_selected, is_collapsed, cx)
1686 .into_any_element()
1687 }
1688 ListEntry::Contact { contact, calling } => self
1689 .render_contact(contact, *calling, is_selected, cx)
1690 .into_any_element(),
1691 ListEntry::ContactPlaceholder => self
1692 .render_contact_placeholder(is_selected, cx)
1693 .into_any_element(),
1694 ListEntry::IncomingRequest(user) => self
1695 .render_contact_request(user, true, is_selected, cx)
1696 .into_any_element(),
1697 ListEntry::OutgoingRequest(user) => self
1698 .render_contact_request(user, false, is_selected, cx)
1699 .into_any_element(),
1700 ListEntry::Channel {
1701 channel,
1702 depth,
1703 has_children,
1704 } => self
1705 .render_channel(channel, *depth, *has_children, is_selected, ix, cx)
1706 .into_any_element(),
1707 ListEntry::ChannelEditor { depth } => {
1708 self.render_channel_editor(*depth, cx).into_any_element()
1709 }
1710 ListEntry::ChannelInvite(channel) => self
1711 .render_channel_invite(channel, is_selected, cx)
1712 .into_any_element(),
1713 ListEntry::CallParticipant {
1714 user,
1715 peer_id,
1716 is_pending,
1717 } => self
1718 .render_call_participant(user, *peer_id, *is_pending, is_selected, cx)
1719 .into_any_element(),
1720 ListEntry::ParticipantProject {
1721 project_id,
1722 worktree_root_names,
1723 host_user_id,
1724 is_last,
1725 } => self
1726 .render_participant_project(
1727 *project_id,
1728 &worktree_root_names,
1729 *host_user_id,
1730 *is_last,
1731 is_selected,
1732 cx,
1733 )
1734 .into_any_element(),
1735 ListEntry::ParticipantScreen { peer_id, is_last } => self
1736 .render_participant_screen(*peer_id, *is_last, is_selected, cx)
1737 .into_any_element(),
1738 ListEntry::ChannelNotes { channel_id } => self
1739 .render_channel_notes(*channel_id, is_selected, cx)
1740 .into_any_element(),
1741 ListEntry::ChannelChat { channel_id } => self
1742 .render_channel_chat(*channel_id, is_selected, cx)
1743 .into_any_element(),
1744 }
1745 }
1746
1747 fn render_signed_in(&mut self, cx: &mut ViewContext<Self>) -> Div {
1748 v_stack()
1749 .size_full()
1750 .child(list(self.list_state.clone()).full())
1751 .child(
1752 v_stack()
1753 .child(div().mx_2().border_primary(cx).border_t())
1754 .child(
1755 v_stack()
1756 .p_2()
1757 .child(self.render_filter_input(&self.filter_editor, cx)),
1758 ),
1759 )
1760 }
1761
1762 fn render_filter_input(
1763 &self,
1764 editor: &View<Editor>,
1765 cx: &mut ViewContext<Self>,
1766 ) -> impl IntoElement {
1767 let settings = ThemeSettings::get_global(cx);
1768 let text_style = TextStyle {
1769 color: if editor.read(cx).read_only() {
1770 cx.theme().colors().text_disabled
1771 } else {
1772 cx.theme().colors().text
1773 },
1774 font_family: settings.ui_font.family.clone(),
1775 font_features: settings.ui_font.features,
1776 font_size: rems(0.875).into(),
1777 font_weight: FontWeight::NORMAL,
1778 font_style: FontStyle::Normal,
1779 line_height: relative(1.3).into(),
1780 background_color: None,
1781 underline: None,
1782 white_space: WhiteSpace::Normal,
1783 };
1784
1785 EditorElement::new(
1786 editor,
1787 EditorStyle {
1788 local_player: cx.theme().players().local(),
1789 text: text_style,
1790 ..Default::default()
1791 },
1792 )
1793 }
1794
1795 fn render_header(
1796 &self,
1797 section: Section,
1798 is_selected: bool,
1799 is_collapsed: bool,
1800 cx: &ViewContext<Self>,
1801 ) -> impl IntoElement {
1802 let mut channel_link = None;
1803 let mut channel_tooltip_text = None;
1804 let mut channel_icon = None;
1805 // let mut is_dragged_over = false;
1806
1807 let text = match section {
1808 Section::ActiveCall => {
1809 let channel_name = maybe!({
1810 let channel_id = ActiveCall::global(cx).read(cx).channel_id(cx)?;
1811
1812 let channel = self.channel_store.read(cx).channel_for_id(channel_id)?;
1813
1814 channel_link = Some(channel.link());
1815 (channel_icon, channel_tooltip_text) = match channel.visibility {
1816 proto::ChannelVisibility::Public => {
1817 (Some("icons/public.svg"), Some("Copy public channel link."))
1818 }
1819 proto::ChannelVisibility::Members => {
1820 (Some("icons/hash.svg"), Some("Copy private channel link."))
1821 }
1822 };
1823
1824 Some(channel.name.as_ref())
1825 });
1826
1827 if let Some(name) = channel_name {
1828 SharedString::from(format!("{}", name))
1829 } else {
1830 SharedString::from("Current Call")
1831 }
1832 }
1833 Section::ContactRequests => SharedString::from("Requests"),
1834 Section::Contacts => SharedString::from("Contacts"),
1835 Section::Channels => SharedString::from("Channels"),
1836 Section::ChannelInvites => SharedString::from("Invites"),
1837 Section::Online => SharedString::from("Online"),
1838 Section::Offline => SharedString::from("Offline"),
1839 };
1840
1841 let button = match section {
1842 Section::ActiveCall => channel_link.map(|channel_link| {
1843 let channel_link_copy = channel_link.clone();
1844 IconButton::new("channel-link", Icon::Copy)
1845 .icon_size(IconSize::Small)
1846 .size(ButtonSize::None)
1847 .visible_on_hover("section-header")
1848 .on_click(move |_, cx| {
1849 let item = ClipboardItem::new(channel_link_copy.clone());
1850 cx.write_to_clipboard(item)
1851 })
1852 .tooltip(|cx| Tooltip::text("Copy channel link", cx))
1853 .into_any_element()
1854 }),
1855 Section::Contacts => Some(
1856 IconButton::new("add-contact", Icon::Plus)
1857 .on_click(cx.listener(|this, _, cx| this.toggle_contact_finder(cx)))
1858 .tooltip(|cx| Tooltip::text("Search for new contact", cx))
1859 .into_any_element(),
1860 ),
1861 Section::Channels => Some(
1862 IconButton::new("add-channel", Icon::Plus)
1863 .on_click(cx.listener(|this, _, cx| this.new_root_channel(cx)))
1864 .tooltip(|cx| Tooltip::text("Create a channel", cx))
1865 .into_any_element(),
1866 ),
1867 _ => None,
1868 };
1869
1870 let can_collapse = match section {
1871 Section::ActiveCall | Section::Channels | Section::Contacts => false,
1872 Section::ChannelInvites
1873 | Section::ContactRequests
1874 | Section::Online
1875 | Section::Offline => true,
1876 };
1877
1878 h_stack()
1879 .w_full()
1880 .group("section-header")
1881 .child(
1882 ListHeader::new(text)
1883 .when(can_collapse, |header| {
1884 header.toggle(Some(!is_collapsed)).on_toggle(cx.listener(
1885 move |this, _, cx| {
1886 this.toggle_section_expanded(section, cx);
1887 },
1888 ))
1889 })
1890 .inset(true)
1891 .end_slot::<AnyElement>(button)
1892 .selected(is_selected),
1893 )
1894 .when(section == Section::Channels, |el| {
1895 el.drag_over::<Channel>(|style| style.bg(cx.theme().colors().ghost_element_hover))
1896 .on_drop(cx.listener(move |this, dragged_channel: &Channel, cx| {
1897 this.channel_store
1898 .update(cx, |channel_store, cx| {
1899 channel_store.move_channel(dragged_channel.id, None, cx)
1900 })
1901 .detach_and_log_err(cx)
1902 }))
1903 })
1904 }
1905
1906 fn render_contact(
1907 &self,
1908 contact: &Contact,
1909 calling: bool,
1910 is_selected: bool,
1911 cx: &mut ViewContext<Self>,
1912 ) -> impl IntoElement {
1913 let online = contact.online;
1914 let busy = contact.busy || calling;
1915 let user_id = contact.user.id;
1916 let github_login = SharedString::from(contact.user.github_login.clone());
1917 let item =
1918 ListItem::new(github_login.clone())
1919 .indent_level(1)
1920 .indent_step_size(px(20.))
1921 .selected(is_selected)
1922 .on_click(cx.listener(move |this, _, cx| this.call(user_id, cx)))
1923 .child(
1924 h_stack()
1925 .w_full()
1926 .justify_between()
1927 .child(Label::new(github_login.clone()))
1928 .when(calling, |el| {
1929 el.child(Label::new("Calling").color(Color::Muted))
1930 })
1931 .when(!calling, |el| {
1932 el.child(
1933 IconButton::new("remove_contact", Icon::Close)
1934 .icon_color(Color::Muted)
1935 .visible_on_hover("")
1936 .tooltip(|cx| Tooltip::text("Remove Contact", cx))
1937 .on_click(cx.listener({
1938 let github_login = github_login.clone();
1939 move |this, _, cx| {
1940 this.remove_contact(user_id, &github_login, cx);
1941 }
1942 })),
1943 )
1944 }),
1945 )
1946 .start_slot(
1947 // todo!() handle contacts with no avatar
1948 Avatar::new(contact.user.avatar_uri.clone())
1949 .availability_indicator(if online { Some(!busy) } else { None }),
1950 )
1951 .when(online && !busy, |el| {
1952 el.on_click(cx.listener(move |this, _, cx| this.call(user_id, cx)))
1953 });
1954
1955 div()
1956 .id(github_login.clone())
1957 .group("")
1958 .child(item)
1959 .tooltip(move |cx| {
1960 let text = if !online {
1961 format!(" {} is offline", &github_login)
1962 } else if busy {
1963 format!(" {} is on a call", &github_login)
1964 } else {
1965 let room = ActiveCall::global(cx).read(cx).room();
1966 if room.is_some() {
1967 format!("Invite {} to join call", &github_login)
1968 } else {
1969 format!("Call {}", &github_login)
1970 }
1971 };
1972 Tooltip::text(text, cx)
1973 })
1974 }
1975
1976 fn render_contact_request(
1977 &self,
1978 user: &Arc<User>,
1979 is_incoming: bool,
1980 is_selected: bool,
1981 cx: &mut ViewContext<Self>,
1982 ) -> impl IntoElement {
1983 let github_login = SharedString::from(user.github_login.clone());
1984 let user_id = user.id;
1985 let is_response_pending = self.user_store.read(cx).is_contact_request_pending(&user);
1986 let color = if is_response_pending {
1987 Color::Muted
1988 } else {
1989 Color::Default
1990 };
1991
1992 let controls = if is_incoming {
1993 vec![
1994 IconButton::new("decline-contact", Icon::Close)
1995 .on_click(cx.listener(move |this, _, cx| {
1996 this.respond_to_contact_request(user_id, false, cx);
1997 }))
1998 .icon_color(color)
1999 .tooltip(|cx| Tooltip::text("Decline invite", cx)),
2000 IconButton::new("accept-contact", Icon::Check)
2001 .on_click(cx.listener(move |this, _, cx| {
2002 this.respond_to_contact_request(user_id, true, cx);
2003 }))
2004 .icon_color(color)
2005 .tooltip(|cx| Tooltip::text("Accept invite", cx)),
2006 ]
2007 } else {
2008 let github_login = github_login.clone();
2009 vec![IconButton::new("remove_contact", Icon::Close)
2010 .on_click(cx.listener(move |this, _, cx| {
2011 this.remove_contact(user_id, &github_login, cx);
2012 }))
2013 .icon_color(color)
2014 .tooltip(|cx| Tooltip::text("Cancel invite", cx))]
2015 };
2016
2017 ListItem::new(github_login.clone())
2018 .indent_level(1)
2019 .indent_step_size(px(20.))
2020 .selected(is_selected)
2021 .child(
2022 h_stack()
2023 .w_full()
2024 .justify_between()
2025 .child(Label::new(github_login.clone()))
2026 .child(h_stack().children(controls)),
2027 )
2028 .start_slot(Avatar::new(user.avatar_uri.clone()))
2029 }
2030
2031 fn render_channel_invite(
2032 &self,
2033 channel: &Arc<Channel>,
2034 is_selected: bool,
2035 cx: &mut ViewContext<Self>,
2036 ) -> ListItem {
2037 let channel_id = channel.id;
2038 let response_is_pending = self
2039 .channel_store
2040 .read(cx)
2041 .has_pending_channel_invite_response(&channel);
2042 let color = if response_is_pending {
2043 Color::Muted
2044 } else {
2045 Color::Default
2046 };
2047
2048 let controls = [
2049 IconButton::new("reject-invite", Icon::Close)
2050 .on_click(cx.listener(move |this, _, cx| {
2051 this.respond_to_channel_invite(channel_id, false, cx);
2052 }))
2053 .icon_color(color)
2054 .tooltip(|cx| Tooltip::text("Decline invite", cx)),
2055 IconButton::new("accept-invite", Icon::Check)
2056 .on_click(cx.listener(move |this, _, cx| {
2057 this.respond_to_channel_invite(channel_id, true, cx);
2058 }))
2059 .icon_color(color)
2060 .tooltip(|cx| Tooltip::text("Accept invite", cx)),
2061 ];
2062
2063 ListItem::new(("channel-invite", channel.id as usize))
2064 .selected(is_selected)
2065 .child(
2066 h_stack()
2067 .w_full()
2068 .justify_between()
2069 .child(Label::new(channel.name.clone()))
2070 .child(h_stack().children(controls)),
2071 )
2072 .start_slot(
2073 IconElement::new(Icon::Hash)
2074 .size(IconSize::Small)
2075 .color(Color::Muted),
2076 )
2077 }
2078
2079 fn render_contact_placeholder(
2080 &self,
2081 is_selected: bool,
2082 cx: &mut ViewContext<Self>,
2083 ) -> ListItem {
2084 ListItem::new("contact-placeholder")
2085 .child(IconElement::new(Icon::Plus))
2086 .child(Label::new("Add a Contact"))
2087 .selected(is_selected)
2088 .on_click(cx.listener(|this, _, cx| this.toggle_contact_finder(cx)))
2089 }
2090
2091 fn render_channel(
2092 &self,
2093 channel: &Channel,
2094 depth: usize,
2095 has_children: bool,
2096 is_selected: bool,
2097 ix: usize,
2098 cx: &mut ViewContext<Self>,
2099 ) -> impl IntoElement {
2100 let channel_id = channel.id;
2101
2102 let is_active = maybe!({
2103 let call_channel = ActiveCall::global(cx)
2104 .read(cx)
2105 .room()?
2106 .read(cx)
2107 .channel_id()?;
2108 Some(call_channel == channel_id)
2109 })
2110 .unwrap_or(false);
2111 let is_public = self
2112 .channel_store
2113 .read(cx)
2114 .channel_for_id(channel_id)
2115 .map(|channel| channel.visibility)
2116 == Some(proto::ChannelVisibility::Public);
2117 let disclosed =
2118 has_children.then(|| !self.collapsed_channels.binary_search(&channel.id).is_ok());
2119
2120 let has_messages_notification = channel.unseen_message_id.is_some();
2121 let has_notes_notification = channel.unseen_note_version.is_some();
2122
2123 const FACEPILE_LIMIT: usize = 3;
2124 let participants = self.channel_store.read(cx).channel_participants(channel_id);
2125
2126 let face_pile = if !participants.is_empty() {
2127 let extra_count = participants.len().saturating_sub(FACEPILE_LIMIT);
2128 let result = FacePile {
2129 faces: participants
2130 .iter()
2131 .map(|user| Avatar::new(user.avatar_uri.clone()).into_any_element())
2132 .take(FACEPILE_LIMIT)
2133 .chain(if extra_count > 0 {
2134 // todo!() @nate - this label looks wrong.
2135 Some(Label::new(format!("+{}", extra_count)).into_any_element())
2136 } else {
2137 None
2138 })
2139 .collect::<SmallVec<_>>(),
2140 };
2141
2142 Some(result)
2143 } else {
2144 None
2145 };
2146
2147 let button_container = |cx: &mut ViewContext<Self>| {
2148 h_stack()
2149 .absolute()
2150 // We're using a negative coordinate for the right anchor to
2151 // counteract the padding of the `ListItem`.
2152 //
2153 // This prevents a gap from showing up between the background
2154 // of this element and the edge of the collab panel.
2155 .right(rems(-0.5))
2156 // HACK: Without this the channel name clips on top of the icons, but I'm not sure why.
2157 .z_index(10)
2158 .bg(cx.theme().colors().panel_background)
2159 .when(is_selected || is_active, |this| {
2160 this.bg(cx.theme().colors().ghost_element_selected)
2161 })
2162 };
2163
2164 let messages_button = |cx: &mut ViewContext<Self>| {
2165 IconButton::new("channel_chat", Icon::MessageBubbles)
2166 .icon_size(IconSize::Small)
2167 .icon_color(if has_messages_notification {
2168 Color::Default
2169 } else {
2170 Color::Muted
2171 })
2172 .on_click(cx.listener(move |this, _, cx| this.join_channel_chat(channel_id, cx)))
2173 .tooltip(|cx| Tooltip::text("Open channel chat", cx))
2174 };
2175
2176 let notes_button = |cx: &mut ViewContext<Self>| {
2177 IconButton::new("channel_notes", Icon::File)
2178 .icon_size(IconSize::Small)
2179 .icon_color(if has_notes_notification {
2180 Color::Default
2181 } else {
2182 Color::Muted
2183 })
2184 .on_click(cx.listener(move |this, _, cx| this.open_channel_notes(channel_id, cx)))
2185 .tooltip(|cx| Tooltip::text("Open channel notes", cx))
2186 };
2187
2188 let width = self.width.unwrap_or(px(240.));
2189
2190 div()
2191 .id(channel_id as usize)
2192 .group("")
2193 .flex()
2194 .w_full()
2195 .on_drag(channel.clone(), move |channel, cx| {
2196 cx.new_view(|_| DraggedChannelView {
2197 channel: channel.clone(),
2198 width,
2199 })
2200 })
2201 .drag_over::<Channel>(|style| style.bg(cx.theme().colors().ghost_element_hover))
2202 .on_drop(cx.listener(move |this, dragged_channel: &Channel, cx| {
2203 this.channel_store
2204 .update(cx, |channel_store, cx| {
2205 channel_store.move_channel(dragged_channel.id, Some(channel_id), cx)
2206 })
2207 .detach_and_log_err(cx)
2208 }))
2209 .child(
2210 ListItem::new(channel_id as usize)
2211 // Add one level of depth for the disclosure arrow.
2212 .indent_level(depth + 1)
2213 .indent_step_size(px(20.))
2214 .selected(is_selected || is_active)
2215 .toggle(disclosed)
2216 .on_toggle(
2217 cx.listener(move |this, _, cx| {
2218 this.toggle_channel_collapsed(channel_id, cx)
2219 }),
2220 )
2221 .on_click(cx.listener(move |this, _, cx| {
2222 if is_active {
2223 this.open_channel_notes(channel_id, cx)
2224 } else {
2225 this.join_channel(channel_id, cx)
2226 }
2227 }))
2228 .on_secondary_mouse_down(cx.listener(
2229 move |this, event: &MouseDownEvent, cx| {
2230 this.deploy_channel_context_menu(event.position, channel_id, ix, cx)
2231 },
2232 ))
2233 .start_slot(
2234 IconElement::new(if is_public { Icon::Public } else { Icon::Hash })
2235 .size(IconSize::Small)
2236 .color(Color::Muted),
2237 )
2238 .child(
2239 h_stack()
2240 .id(channel_id as usize)
2241 // HACK: This is a dirty hack to help with the positioning of the button container.
2242 //
2243 // We're using a pixel width for the elements but then allowing the contents to
2244 // overflow. This means that the label and facepile will be shown, but will not
2245 // push the button container off the edge of the panel.
2246 .w_px()
2247 .child(Label::new(channel.name.clone()))
2248 .children(face_pile.map(|face_pile| face_pile.render(cx))),
2249 )
2250 .end_slot::<Div>(
2251 // If we have a notification for either button, we want to show the corresponding
2252 // button(s) as indicators.
2253 if has_messages_notification || has_notes_notification {
2254 Some(
2255 button_container(cx).child(
2256 h_stack()
2257 .px_1()
2258 .children(
2259 // We only want to render the messages button if there are unseen messages.
2260 // This way we don't take up any space that might overlap the channel name
2261 // when there are no notifications.
2262 has_messages_notification.then(|| messages_button(cx)),
2263 )
2264 .child(
2265 // We always want the notes button to take up space to prevent layout
2266 // shift when hovering over the channel.
2267 // However, if there are is no notes notification we just show an empty slot.
2268 notes_button(cx)
2269 .when(!has_notes_notification, |this| {
2270 this.visible_on_hover("")
2271 }),
2272 ),
2273 ),
2274 )
2275 } else {
2276 None
2277 },
2278 )
2279 .end_hover_slot(
2280 // When we hover the channel entry we want to always show both buttons.
2281 button_container(cx).child(
2282 h_stack()
2283 .px_1()
2284 // The element hover background has a slight transparency to it, so we
2285 // need to apply it to the inner element so that it blends with the solid
2286 // background color of the absolutely-positioned element.
2287 .group_hover("", |style| {
2288 style.bg(cx.theme().colors().ghost_element_hover)
2289 })
2290 .child(messages_button(cx))
2291 .child(notes_button(cx)),
2292 ),
2293 ),
2294 )
2295 .tooltip(|cx| Tooltip::text("Join channel", cx))
2296 }
2297
2298 fn render_channel_editor(&self, depth: usize, _cx: &mut ViewContext<Self>) -> impl IntoElement {
2299 let item = ListItem::new("channel-editor")
2300 .inset(false)
2301 // Add one level of depth for the disclosure arrow.
2302 .indent_level(depth + 1)
2303 .indent_step_size(px(20.))
2304 .start_slot(
2305 IconElement::new(Icon::Hash)
2306 .size(IconSize::Small)
2307 .color(Color::Muted),
2308 );
2309
2310 if let Some(pending_name) = self
2311 .channel_editing_state
2312 .as_ref()
2313 .and_then(|state| state.pending_name())
2314 {
2315 item.child(Label::new(pending_name))
2316 } else {
2317 item.child(
2318 div()
2319 .w_full()
2320 .py_1() // todo!() @nate this is a px off at the default font size.
2321 .child(self.channel_name_editor.clone()),
2322 )
2323 }
2324 }
2325}
2326
2327fn render_tree_branch(is_last: bool, cx: &mut WindowContext) -> impl IntoElement {
2328 let rem_size = cx.rem_size();
2329 let line_height = cx.text_style().line_height_in_pixels(rem_size);
2330 let width = rem_size * 1.5;
2331 let thickness = px(2.);
2332 let color = cx.theme().colors().text;
2333
2334 canvas(move |bounds, cx| {
2335 let start_x = (bounds.left() + bounds.right() - thickness) / 2.;
2336 let start_y = (bounds.top() + bounds.bottom() - thickness) / 2.;
2337 let right = bounds.right();
2338 let top = bounds.top();
2339
2340 cx.paint_quad(fill(
2341 Bounds::from_corners(
2342 point(start_x, top),
2343 point(
2344 start_x + thickness,
2345 if is_last { start_y } else { bounds.bottom() },
2346 ),
2347 ),
2348 color,
2349 ));
2350 cx.paint_quad(fill(
2351 Bounds::from_corners(point(start_x, start_y), point(right, start_y + thickness)),
2352 color,
2353 ));
2354 })
2355 .w(width)
2356 .h(line_height)
2357}
2358
2359impl Render for CollabPanel {
2360 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
2361 v_stack()
2362 .key_context("CollabPanel")
2363 .on_action(cx.listener(CollabPanel::cancel))
2364 .on_action(cx.listener(CollabPanel::select_next))
2365 .on_action(cx.listener(CollabPanel::select_prev))
2366 .on_action(cx.listener(CollabPanel::confirm))
2367 .on_action(cx.listener(CollabPanel::insert_space))
2368 .on_action(cx.listener(CollabPanel::remove_selected_channel))
2369 .on_action(cx.listener(CollabPanel::show_inline_context_menu))
2370 .on_action(cx.listener(CollabPanel::rename_selected_channel))
2371 .on_action(cx.listener(CollabPanel::collapse_selected_channel))
2372 .on_action(cx.listener(CollabPanel::expand_selected_channel))
2373 .on_action(cx.listener(CollabPanel::start_move_selected_channel))
2374 .track_focus(&self.focus_handle)
2375 .size_full()
2376 .child(if self.user_store.read(cx).current_user().is_none() {
2377 self.render_signed_out(cx)
2378 } else {
2379 self.render_signed_in(cx)
2380 })
2381 .children(self.context_menu.as_ref().map(|(menu, position, _)| {
2382 overlay()
2383 .position(*position)
2384 .anchor(gpui::AnchorCorner::TopLeft)
2385 .child(menu.clone())
2386 }))
2387 }
2388}
2389
2390impl EventEmitter<PanelEvent> for CollabPanel {}
2391
2392impl Panel for CollabPanel {
2393 fn position(&self, cx: &gpui::WindowContext) -> DockPosition {
2394 CollaborationPanelSettings::get_global(cx).dock
2395 }
2396
2397 fn position_is_valid(&self, position: DockPosition) -> bool {
2398 matches!(position, DockPosition::Left | DockPosition::Right)
2399 }
2400
2401 fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
2402 settings::update_settings_file::<CollaborationPanelSettings>(
2403 self.fs.clone(),
2404 cx,
2405 move |settings| settings.dock = Some(position),
2406 );
2407 }
2408
2409 fn size(&self, cx: &gpui::WindowContext) -> Pixels {
2410 self.width
2411 .unwrap_or_else(|| CollaborationPanelSettings::get_global(cx).default_width)
2412 }
2413
2414 fn set_size(&mut self, size: Option<Pixels>, cx: &mut ViewContext<Self>) {
2415 self.width = size;
2416 self.serialize(cx);
2417 cx.notify();
2418 }
2419
2420 fn icon(&self, cx: &gpui::WindowContext) -> Option<ui::Icon> {
2421 CollaborationPanelSettings::get_global(cx)
2422 .button
2423 .then(|| ui::Icon::Collab)
2424 }
2425
2426 fn icon_tooltip(&self, _cx: &WindowContext) -> Option<&'static str> {
2427 Some("Collab Panel")
2428 }
2429
2430 fn toggle_action(&self) -> Box<dyn gpui::Action> {
2431 Box::new(ToggleFocus)
2432 }
2433
2434 fn persistent_name() -> &'static str {
2435 "CollabPanel"
2436 }
2437}
2438
2439impl FocusableView for CollabPanel {
2440 fn focus_handle(&self, cx: &AppContext) -> gpui::FocusHandle {
2441 self.filter_editor.focus_handle(cx).clone()
2442 }
2443}
2444
2445impl PartialEq for ListEntry {
2446 fn eq(&self, other: &Self) -> bool {
2447 match self {
2448 ListEntry::Header(section_1) => {
2449 if let ListEntry::Header(section_2) = other {
2450 return section_1 == section_2;
2451 }
2452 }
2453 ListEntry::CallParticipant { user: user_1, .. } => {
2454 if let ListEntry::CallParticipant { user: user_2, .. } = other {
2455 return user_1.id == user_2.id;
2456 }
2457 }
2458 ListEntry::ParticipantProject {
2459 project_id: project_id_1,
2460 ..
2461 } => {
2462 if let ListEntry::ParticipantProject {
2463 project_id: project_id_2,
2464 ..
2465 } = other
2466 {
2467 return project_id_1 == project_id_2;
2468 }
2469 }
2470 ListEntry::ParticipantScreen {
2471 peer_id: peer_id_1, ..
2472 } => {
2473 if let ListEntry::ParticipantScreen {
2474 peer_id: peer_id_2, ..
2475 } = other
2476 {
2477 return peer_id_1 == peer_id_2;
2478 }
2479 }
2480 ListEntry::Channel {
2481 channel: channel_1, ..
2482 } => {
2483 if let ListEntry::Channel {
2484 channel: channel_2, ..
2485 } = other
2486 {
2487 return channel_1.id == channel_2.id;
2488 }
2489 }
2490 ListEntry::ChannelNotes { channel_id } => {
2491 if let ListEntry::ChannelNotes {
2492 channel_id: other_id,
2493 } = other
2494 {
2495 return channel_id == other_id;
2496 }
2497 }
2498 ListEntry::ChannelChat { channel_id } => {
2499 if let ListEntry::ChannelChat {
2500 channel_id: other_id,
2501 } = other
2502 {
2503 return channel_id == other_id;
2504 }
2505 }
2506 ListEntry::ChannelInvite(channel_1) => {
2507 if let ListEntry::ChannelInvite(channel_2) = other {
2508 return channel_1.id == channel_2.id;
2509 }
2510 }
2511 ListEntry::IncomingRequest(user_1) => {
2512 if let ListEntry::IncomingRequest(user_2) = other {
2513 return user_1.id == user_2.id;
2514 }
2515 }
2516 ListEntry::OutgoingRequest(user_1) => {
2517 if let ListEntry::OutgoingRequest(user_2) = other {
2518 return user_1.id == user_2.id;
2519 }
2520 }
2521 ListEntry::Contact {
2522 contact: contact_1, ..
2523 } => {
2524 if let ListEntry::Contact {
2525 contact: contact_2, ..
2526 } = other
2527 {
2528 return contact_1.user.id == contact_2.user.id;
2529 }
2530 }
2531 ListEntry::ChannelEditor { depth } => {
2532 if let ListEntry::ChannelEditor { depth: other_depth } = other {
2533 return depth == other_depth;
2534 }
2535 }
2536 ListEntry::ContactPlaceholder => {
2537 if let ListEntry::ContactPlaceholder = other {
2538 return true;
2539 }
2540 }
2541 }
2542 false
2543 }
2544}
2545
2546struct DraggedChannelView {
2547 channel: Channel,
2548 width: Pixels,
2549}
2550
2551impl Render for DraggedChannelView {
2552 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl Element {
2553 let ui_font = ThemeSettings::get_global(cx).ui_font.family.clone();
2554 h_stack()
2555 .font(ui_font)
2556 .bg(cx.theme().colors().background)
2557 .w(self.width)
2558 .p_1()
2559 .gap_1()
2560 .child(
2561 IconElement::new(
2562 if self.channel.visibility == proto::ChannelVisibility::Public {
2563 Icon::Public
2564 } else {
2565 Icon::Hash
2566 },
2567 )
2568 .size(IconSize::Small)
2569 .color(Color::Muted),
2570 )
2571 .child(Label::new(self.channel.name.clone()))
2572 }
2573}