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