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