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.move_channel(clipboard.channel_id, to_channel_id, cx)
1589 }
1590 }
1591
1592 fn move_channel(&self, channel_id: ChannelId, to: ChannelId, cx: &mut ViewContext<Self>) {
1593 self.channel_store
1594 .update(cx, |channel_store, cx| {
1595 channel_store.move_channel(channel_id, to, cx)
1596 })
1597 .detach_and_prompt_err("Failed to move channel", cx, |e, _| match e.error_code() {
1598 ErrorCode::BadPublicNesting => {
1599 Some("Public channels must have public parents".into())
1600 }
1601 ErrorCode::CircularNesting => Some("You cannot move a channel into itself".into()),
1602 ErrorCode::WrongMoveTarget => {
1603 Some("You cannot move a channel into a different root channel".into())
1604 }
1605 _ => None,
1606 })
1607 }
1608
1609 fn open_channel_notes(&mut self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
1610 if let Some(workspace) = self.workspace.upgrade() {
1611 ChannelView::open(channel_id, workspace, cx).detach();
1612 }
1613 }
1614
1615 fn show_inline_context_menu(&mut self, _: &menu::ShowContextMenu, cx: &mut ViewContext<Self>) {
1616 let Some(channel) = self.selected_channel() else {
1617 return;
1618 };
1619 let Some(bounds) = self
1620 .selection
1621 .and_then(|ix| self.list_state.bounds_for_item(ix))
1622 else {
1623 return;
1624 };
1625
1626 self.deploy_channel_context_menu(bounds.center(), channel.id, self.selection.unwrap(), cx);
1627 cx.stop_propagation();
1628 }
1629
1630 fn selected_channel(&self) -> Option<&Arc<Channel>> {
1631 self.selection
1632 .and_then(|ix| self.entries.get(ix))
1633 .and_then(|entry| match entry {
1634 ListEntry::Channel { channel, .. } => Some(channel),
1635 _ => None,
1636 })
1637 }
1638
1639 fn show_channel_modal(
1640 &mut self,
1641 channel_id: ChannelId,
1642 mode: channel_modal::Mode,
1643 cx: &mut ViewContext<Self>,
1644 ) {
1645 let workspace = self.workspace.clone();
1646 let user_store = self.user_store.clone();
1647 let channel_store = self.channel_store.clone();
1648 let members = self.channel_store.update(cx, |channel_store, cx| {
1649 channel_store.get_channel_member_details(channel_id, cx)
1650 });
1651
1652 cx.spawn(|_, mut cx| async move {
1653 let members = members.await?;
1654 workspace.update(&mut cx, |workspace, cx| {
1655 workspace.toggle_modal(cx, |cx| {
1656 ChannelModal::new(
1657 user_store.clone(),
1658 channel_store.clone(),
1659 channel_id,
1660 mode,
1661 members,
1662 cx,
1663 )
1664 });
1665 })
1666 })
1667 .detach();
1668 }
1669
1670 fn remove_channel(&mut self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
1671 let channel_store = self.channel_store.clone();
1672 if let Some(channel) = channel_store.read(cx).channel_for_id(channel_id) {
1673 let prompt_message = format!(
1674 "Are you sure you want to remove the channel \"{}\"?",
1675 channel.name
1676 );
1677 let answer = cx.prompt(
1678 PromptLevel::Warning,
1679 &prompt_message,
1680 None,
1681 &["Remove", "Cancel"],
1682 );
1683 cx.spawn(|this, mut cx| async move {
1684 if answer.await? == 0 {
1685 channel_store
1686 .update(&mut cx, |channels, _| channels.remove_channel(channel_id))?
1687 .await
1688 .notify_async_err(&mut cx);
1689 this.update(&mut cx, |_, cx| cx.focus_self()).ok();
1690 }
1691 anyhow::Ok(())
1692 })
1693 .detach();
1694 }
1695 }
1696
1697 fn remove_contact(&mut self, user_id: u64, github_login: &str, cx: &mut ViewContext<Self>) {
1698 let user_store = self.user_store.clone();
1699 let prompt_message = format!(
1700 "Are you sure you want to remove \"{}\" from your contacts?",
1701 github_login
1702 );
1703 let answer = cx.prompt(
1704 PromptLevel::Warning,
1705 &prompt_message,
1706 None,
1707 &["Remove", "Cancel"],
1708 );
1709 cx.spawn(|_, mut cx| async move {
1710 if answer.await? == 0 {
1711 user_store
1712 .update(&mut cx, |store, cx| store.remove_contact(user_id, cx))?
1713 .await
1714 .notify_async_err(&mut cx);
1715 }
1716 anyhow::Ok(())
1717 })
1718 .detach_and_prompt_err("Failed to remove contact", cx, |_, _| None);
1719 }
1720
1721 fn respond_to_contact_request(
1722 &mut self,
1723 user_id: u64,
1724 accept: bool,
1725 cx: &mut ViewContext<Self>,
1726 ) {
1727 self.user_store
1728 .update(cx, |store, cx| {
1729 store.respond_to_contact_request(user_id, accept, cx)
1730 })
1731 .detach_and_prompt_err("Failed to respond to contact request", cx, |_, _| None);
1732 }
1733
1734 fn respond_to_channel_invite(
1735 &mut self,
1736 channel_id: u64,
1737 accept: bool,
1738 cx: &mut ViewContext<Self>,
1739 ) {
1740 self.channel_store
1741 .update(cx, |store, cx| {
1742 store.respond_to_channel_invite(channel_id, accept, cx)
1743 })
1744 .detach();
1745 }
1746
1747 fn call(&mut self, recipient_user_id: u64, cx: &mut ViewContext<Self>) {
1748 ActiveCall::global(cx)
1749 .update(cx, |call, cx| {
1750 call.invite(recipient_user_id, Some(self.project.clone()), cx)
1751 })
1752 .detach_and_prompt_err("Call failed", cx, |_, _| None);
1753 }
1754
1755 fn join_channel(&self, channel_id: u64, cx: &mut ViewContext<Self>) {
1756 let Some(workspace) = self.workspace.upgrade() else {
1757 return;
1758 };
1759 let Some(handle) = cx.window_handle().downcast::<Workspace>() else {
1760 return;
1761 };
1762 workspace::join_channel(
1763 channel_id,
1764 workspace.read(cx).app_state().clone(),
1765 Some(handle),
1766 cx,
1767 )
1768 .detach_and_prompt_err("Failed to join channel", cx, |_, _| None)
1769 }
1770
1771 fn join_channel_chat(&mut self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
1772 let Some(workspace) = self.workspace.upgrade() else {
1773 return;
1774 };
1775 cx.window_context().defer(move |cx| {
1776 workspace.update(cx, |workspace, cx| {
1777 if let Some(panel) = workspace.focus_panel::<ChatPanel>(cx) {
1778 panel.update(cx, |panel, cx| {
1779 panel
1780 .select_channel(channel_id, None, cx)
1781 .detach_and_notify_err(cx);
1782 });
1783 }
1784 });
1785 });
1786 }
1787
1788 fn copy_channel_link(&mut self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
1789 let channel_store = self.channel_store.read(cx);
1790 let Some(channel) = channel_store.channel_for_id(channel_id) else {
1791 return;
1792 };
1793 let item = ClipboardItem::new(channel.link());
1794 cx.write_to_clipboard(item)
1795 }
1796
1797 fn render_signed_out(&mut self, cx: &mut ViewContext<Self>) -> Div {
1798 let collab_blurb = "Work with your team in realtime with collaborative editing, voice, shared notes and more.";
1799
1800 v_flex()
1801 .gap_6()
1802 .p_4()
1803 .child(Label::new(collab_blurb))
1804 .child(
1805 v_flex()
1806 .gap_2()
1807 .child(
1808 Button::new("sign_in", "Sign in")
1809 .icon_color(Color::Muted)
1810 .icon(IconName::Github)
1811 .icon_position(IconPosition::Start)
1812 .style(ButtonStyle::Filled)
1813 .full_width()
1814 .on_click(cx.listener(|this, _, cx| {
1815 let client = this.client.clone();
1816 cx.spawn(|_, mut cx| async move {
1817 client
1818 .authenticate_and_connect(true, &cx)
1819 .await
1820 .notify_async_err(&mut cx);
1821 })
1822 .detach()
1823 })),
1824 )
1825 .child(
1826 div().flex().w_full().items_center().child(
1827 Label::new("Sign in to enable collaboration.")
1828 .color(Color::Muted)
1829 .size(LabelSize::Small),
1830 ),
1831 ),
1832 )
1833 }
1834
1835 fn render_list_entry(&mut self, ix: usize, cx: &mut ViewContext<Self>) -> AnyElement {
1836 let entry = &self.entries[ix];
1837
1838 let is_selected = self.selection == Some(ix);
1839 match entry {
1840 ListEntry::Header(section) => {
1841 let is_collapsed = self.collapsed_sections.contains(section);
1842 self.render_header(*section, is_selected, is_collapsed, cx)
1843 .into_any_element()
1844 }
1845 ListEntry::Contact { contact, calling } => self
1846 .render_contact(contact, *calling, is_selected, cx)
1847 .into_any_element(),
1848 ListEntry::ContactPlaceholder => self
1849 .render_contact_placeholder(is_selected, cx)
1850 .into_any_element(),
1851 ListEntry::IncomingRequest(user) => self
1852 .render_contact_request(user, true, is_selected, cx)
1853 .into_any_element(),
1854 ListEntry::OutgoingRequest(user) => self
1855 .render_contact_request(user, false, is_selected, cx)
1856 .into_any_element(),
1857 ListEntry::Channel {
1858 channel,
1859 depth,
1860 has_children,
1861 } => self
1862 .render_channel(channel, *depth, *has_children, is_selected, ix, cx)
1863 .into_any_element(),
1864 ListEntry::ChannelEditor { depth } => {
1865 self.render_channel_editor(*depth, cx).into_any_element()
1866 }
1867 ListEntry::ChannelInvite(channel) => self
1868 .render_channel_invite(channel, is_selected, cx)
1869 .into_any_element(),
1870 ListEntry::CallParticipant {
1871 user,
1872 peer_id,
1873 is_pending,
1874 role,
1875 } => self
1876 .render_call_participant(user, *peer_id, *is_pending, *role, is_selected, cx)
1877 .into_any_element(),
1878 ListEntry::ParticipantProject {
1879 project_id,
1880 worktree_root_names,
1881 host_user_id,
1882 is_last,
1883 } => self
1884 .render_participant_project(
1885 *project_id,
1886 &worktree_root_names,
1887 *host_user_id,
1888 *is_last,
1889 is_selected,
1890 cx,
1891 )
1892 .into_any_element(),
1893 ListEntry::ParticipantScreen { peer_id, is_last } => self
1894 .render_participant_screen(*peer_id, *is_last, is_selected, cx)
1895 .into_any_element(),
1896 ListEntry::ChannelNotes { channel_id } => self
1897 .render_channel_notes(*channel_id, is_selected, cx)
1898 .into_any_element(),
1899 ListEntry::ChannelChat { channel_id } => self
1900 .render_channel_chat(*channel_id, is_selected, cx)
1901 .into_any_element(),
1902 }
1903 }
1904
1905 fn render_signed_in(&mut self, cx: &mut ViewContext<Self>) -> Div {
1906 v_flex()
1907 .size_full()
1908 .child(list(self.list_state.clone()).full())
1909 .child(
1910 v_flex()
1911 .child(div().mx_2().border_primary(cx).border_t())
1912 .child(
1913 v_flex()
1914 .p_2()
1915 .child(self.render_filter_input(&self.filter_editor, cx)),
1916 ),
1917 )
1918 }
1919
1920 fn render_filter_input(
1921 &self,
1922 editor: &View<Editor>,
1923 cx: &mut ViewContext<Self>,
1924 ) -> impl IntoElement {
1925 let settings = ThemeSettings::get_global(cx);
1926 let text_style = TextStyle {
1927 color: if editor.read(cx).read_only(cx) {
1928 cx.theme().colors().text_disabled
1929 } else {
1930 cx.theme().colors().text
1931 },
1932 font_family: settings.ui_font.family.clone(),
1933 font_features: settings.ui_font.features,
1934 font_size: rems(0.875).into(),
1935 font_weight: FontWeight::NORMAL,
1936 font_style: FontStyle::Normal,
1937 line_height: relative(1.3).into(),
1938 background_color: None,
1939 underline: None,
1940 white_space: WhiteSpace::Normal,
1941 };
1942
1943 EditorElement::new(
1944 editor,
1945 EditorStyle {
1946 local_player: cx.theme().players().local(),
1947 text: text_style,
1948 ..Default::default()
1949 },
1950 )
1951 }
1952
1953 fn render_header(
1954 &self,
1955 section: Section,
1956 is_selected: bool,
1957 is_collapsed: bool,
1958 cx: &ViewContext<Self>,
1959 ) -> impl IntoElement {
1960 let mut channel_link = None;
1961 let mut channel_tooltip_text = None;
1962 let mut channel_icon = None;
1963
1964 let text = match section {
1965 Section::ActiveCall => {
1966 let channel_name = maybe!({
1967 let channel_id = ActiveCall::global(cx).read(cx).channel_id(cx)?;
1968
1969 let channel = self.channel_store.read(cx).channel_for_id(channel_id)?;
1970
1971 channel_link = Some(channel.link());
1972 (channel_icon, channel_tooltip_text) = match channel.visibility {
1973 proto::ChannelVisibility::Public => {
1974 (Some("icons/public.svg"), Some("Copy public channel link."))
1975 }
1976 proto::ChannelVisibility::Members => {
1977 (Some("icons/hash.svg"), Some("Copy private channel link."))
1978 }
1979 };
1980
1981 Some(channel.name.as_ref())
1982 });
1983
1984 if let Some(name) = channel_name {
1985 SharedString::from(format!("{}", name))
1986 } else {
1987 SharedString::from("Current Call")
1988 }
1989 }
1990 Section::ContactRequests => SharedString::from("Requests"),
1991 Section::Contacts => SharedString::from("Contacts"),
1992 Section::Channels => SharedString::from("Channels"),
1993 Section::ChannelInvites => SharedString::from("Invites"),
1994 Section::Online => SharedString::from("Online"),
1995 Section::Offline => SharedString::from("Offline"),
1996 };
1997
1998 let button = match section {
1999 Section::ActiveCall => channel_link.map(|channel_link| {
2000 let channel_link_copy = channel_link.clone();
2001 IconButton::new("channel-link", IconName::Copy)
2002 .icon_size(IconSize::Small)
2003 .size(ButtonSize::None)
2004 .visible_on_hover("section-header")
2005 .on_click(move |_, cx| {
2006 let item = ClipboardItem::new(channel_link_copy.clone());
2007 cx.write_to_clipboard(item)
2008 })
2009 .tooltip(|cx| Tooltip::text("Copy channel link", cx))
2010 .into_any_element()
2011 }),
2012 Section::Contacts => Some(
2013 IconButton::new("add-contact", IconName::Plus)
2014 .on_click(cx.listener(|this, _, cx| this.toggle_contact_finder(cx)))
2015 .tooltip(|cx| Tooltip::text("Search for new contact", cx))
2016 .into_any_element(),
2017 ),
2018 Section::Channels => Some(
2019 IconButton::new("add-channel", IconName::Plus)
2020 .on_click(cx.listener(|this, _, cx| this.new_root_channel(cx)))
2021 .tooltip(|cx| Tooltip::text("Create a channel", cx))
2022 .into_any_element(),
2023 ),
2024 _ => None,
2025 };
2026
2027 let can_collapse = match section {
2028 Section::ActiveCall | Section::Channels | Section::Contacts => false,
2029 Section::ChannelInvites
2030 | Section::ContactRequests
2031 | Section::Online
2032 | Section::Offline => true,
2033 };
2034
2035 h_flex().w_full().group("section-header").child(
2036 ListHeader::new(text)
2037 .when(can_collapse, |header| {
2038 header
2039 .toggle(Some(!is_collapsed))
2040 .on_toggle(cx.listener(move |this, _, cx| {
2041 this.toggle_section_expanded(section, cx);
2042 }))
2043 })
2044 .inset(true)
2045 .end_slot::<AnyElement>(button)
2046 .selected(is_selected),
2047 )
2048 }
2049
2050 fn render_contact(
2051 &self,
2052 contact: &Contact,
2053 calling: bool,
2054 is_selected: bool,
2055 cx: &mut ViewContext<Self>,
2056 ) -> impl IntoElement {
2057 let online = contact.online;
2058 let busy = contact.busy || calling;
2059 let user_id = contact.user.id;
2060 let github_login = SharedString::from(contact.user.github_login.clone());
2061 let item = ListItem::new(github_login.clone())
2062 .indent_level(1)
2063 .indent_step_size(px(20.))
2064 .selected(is_selected)
2065 .on_click(cx.listener(move |this, _, cx| this.call(user_id, cx)))
2066 .child(
2067 h_flex()
2068 .w_full()
2069 .justify_between()
2070 .child(Label::new(github_login.clone()))
2071 .when(calling, |el| {
2072 el.child(Label::new("Calling").color(Color::Muted))
2073 })
2074 .when(!calling, |el| {
2075 el.child(
2076 IconButton::new("remove_contact", IconName::Close)
2077 .icon_color(Color::Muted)
2078 .visible_on_hover("")
2079 .tooltip(|cx| Tooltip::text("Remove Contact", cx))
2080 .on_click(cx.listener({
2081 let github_login = github_login.clone();
2082 move |this, _, cx| {
2083 this.remove_contact(user_id, &github_login, cx);
2084 }
2085 })),
2086 )
2087 }),
2088 )
2089 .start_slot(
2090 // todo handle contacts with no avatar
2091 Avatar::new(contact.user.avatar_uri.clone())
2092 .indicator::<AvatarAvailabilityIndicator>(if online {
2093 Some(AvatarAvailabilityIndicator::new(match busy {
2094 true => ui::Availability::Busy,
2095 false => ui::Availability::Free,
2096 }))
2097 } else {
2098 None
2099 }),
2100 )
2101 .when(online && !busy, |el| {
2102 el.on_click(cx.listener(move |this, _, cx| this.call(user_id, cx)))
2103 });
2104
2105 div()
2106 .id(github_login.clone())
2107 .group("")
2108 .child(item)
2109 .tooltip(move |cx| {
2110 let text = if !online {
2111 format!(" {} is offline", &github_login)
2112 } else if busy {
2113 format!(" {} is on a call", &github_login)
2114 } else {
2115 let room = ActiveCall::global(cx).read(cx).room();
2116 if room.is_some() {
2117 format!("Invite {} to join call", &github_login)
2118 } else {
2119 format!("Call {}", &github_login)
2120 }
2121 };
2122 Tooltip::text(text, cx)
2123 })
2124 }
2125
2126 fn render_contact_request(
2127 &self,
2128 user: &Arc<User>,
2129 is_incoming: bool,
2130 is_selected: bool,
2131 cx: &mut ViewContext<Self>,
2132 ) -> impl IntoElement {
2133 let github_login = SharedString::from(user.github_login.clone());
2134 let user_id = user.id;
2135 let is_response_pending = self.user_store.read(cx).is_contact_request_pending(&user);
2136 let color = if is_response_pending {
2137 Color::Muted
2138 } else {
2139 Color::Default
2140 };
2141
2142 let controls = if is_incoming {
2143 vec![
2144 IconButton::new("decline-contact", IconName::Close)
2145 .on_click(cx.listener(move |this, _, cx| {
2146 this.respond_to_contact_request(user_id, false, cx);
2147 }))
2148 .icon_color(color)
2149 .tooltip(|cx| Tooltip::text("Decline invite", cx)),
2150 IconButton::new("accept-contact", IconName::Check)
2151 .on_click(cx.listener(move |this, _, cx| {
2152 this.respond_to_contact_request(user_id, true, cx);
2153 }))
2154 .icon_color(color)
2155 .tooltip(|cx| Tooltip::text("Accept invite", cx)),
2156 ]
2157 } else {
2158 let github_login = github_login.clone();
2159 vec![IconButton::new("remove_contact", IconName::Close)
2160 .on_click(cx.listener(move |this, _, cx| {
2161 this.remove_contact(user_id, &github_login, cx);
2162 }))
2163 .icon_color(color)
2164 .tooltip(|cx| Tooltip::text("Cancel invite", cx))]
2165 };
2166
2167 ListItem::new(github_login.clone())
2168 .indent_level(1)
2169 .indent_step_size(px(20.))
2170 .selected(is_selected)
2171 .child(
2172 h_flex()
2173 .w_full()
2174 .justify_between()
2175 .child(Label::new(github_login.clone()))
2176 .child(h_flex().children(controls)),
2177 )
2178 .start_slot(Avatar::new(user.avatar_uri.clone()))
2179 }
2180
2181 fn render_channel_invite(
2182 &self,
2183 channel: &Arc<Channel>,
2184 is_selected: bool,
2185 cx: &mut ViewContext<Self>,
2186 ) -> ListItem {
2187 let channel_id = channel.id;
2188 let response_is_pending = self
2189 .channel_store
2190 .read(cx)
2191 .has_pending_channel_invite_response(&channel);
2192 let color = if response_is_pending {
2193 Color::Muted
2194 } else {
2195 Color::Default
2196 };
2197
2198 let controls = [
2199 IconButton::new("reject-invite", IconName::Close)
2200 .on_click(cx.listener(move |this, _, cx| {
2201 this.respond_to_channel_invite(channel_id, false, cx);
2202 }))
2203 .icon_color(color)
2204 .tooltip(|cx| Tooltip::text("Decline invite", cx)),
2205 IconButton::new("accept-invite", IconName::Check)
2206 .on_click(cx.listener(move |this, _, cx| {
2207 this.respond_to_channel_invite(channel_id, true, cx);
2208 }))
2209 .icon_color(color)
2210 .tooltip(|cx| Tooltip::text("Accept invite", cx)),
2211 ];
2212
2213 ListItem::new(("channel-invite", channel.id as usize))
2214 .selected(is_selected)
2215 .child(
2216 h_flex()
2217 .w_full()
2218 .justify_between()
2219 .child(Label::new(channel.name.clone()))
2220 .child(h_flex().children(controls)),
2221 )
2222 .start_slot(
2223 Icon::new(IconName::Hash)
2224 .size(IconSize::Small)
2225 .color(Color::Muted),
2226 )
2227 }
2228
2229 fn render_contact_placeholder(
2230 &self,
2231 is_selected: bool,
2232 cx: &mut ViewContext<Self>,
2233 ) -> ListItem {
2234 ListItem::new("contact-placeholder")
2235 .child(Icon::new(IconName::Plus))
2236 .child(Label::new("Add a Contact"))
2237 .selected(is_selected)
2238 .on_click(cx.listener(|this, _, cx| this.toggle_contact_finder(cx)))
2239 }
2240
2241 fn render_channel(
2242 &self,
2243 channel: &Channel,
2244 depth: usize,
2245 has_children: bool,
2246 is_selected: bool,
2247 ix: usize,
2248 cx: &mut ViewContext<Self>,
2249 ) -> impl IntoElement {
2250 let channel_id = channel.id;
2251
2252 let is_active = maybe!({
2253 let call_channel = ActiveCall::global(cx)
2254 .read(cx)
2255 .room()?
2256 .read(cx)
2257 .channel_id()?;
2258 Some(call_channel == channel_id)
2259 })
2260 .unwrap_or(false);
2261 let channel_store = self.channel_store.read(cx);
2262 let is_public = channel_store
2263 .channel_for_id(channel_id)
2264 .map(|channel| channel.visibility)
2265 == Some(proto::ChannelVisibility::Public);
2266 let disclosed =
2267 has_children.then(|| !self.collapsed_channels.binary_search(&channel.id).is_ok());
2268
2269 let has_messages_notification = channel_store.has_new_messages(channel_id);
2270 let has_notes_notification = channel_store.has_channel_buffer_changed(channel_id);
2271
2272 const FACEPILE_LIMIT: usize = 3;
2273 let participants = self.channel_store.read(cx).channel_participants(channel_id);
2274
2275 let face_pile = if !participants.is_empty() {
2276 let extra_count = participants.len().saturating_sub(FACEPILE_LIMIT);
2277 let result = FacePile::new(
2278 participants
2279 .iter()
2280 .map(|user| Avatar::new(user.avatar_uri.clone()).into_any_element())
2281 .take(FACEPILE_LIMIT)
2282 .chain(if extra_count > 0 {
2283 Some(
2284 div()
2285 .ml_2()
2286 .child(Label::new(format!("+{extra_count}")))
2287 .into_any_element(),
2288 )
2289 } else {
2290 None
2291 })
2292 .collect::<SmallVec<_>>(),
2293 );
2294
2295 Some(result)
2296 } else {
2297 None
2298 };
2299
2300 let width = self.width.unwrap_or(px(240.));
2301 let root_id = channel.root_id();
2302
2303 div()
2304 .id(channel_id as usize)
2305 .group("")
2306 .flex()
2307 .w_full()
2308 .when(!channel.is_root_channel(), |el| {
2309 el.on_drag(channel.clone(), move |channel, cx| {
2310 cx.new_view(|_| DraggedChannelView {
2311 channel: channel.clone(),
2312 width,
2313 })
2314 })
2315 })
2316 .drag_over::<Channel>({
2317 move |style, dragged_channel: &Channel, cx| {
2318 if dragged_channel.root_id() == root_id {
2319 style.bg(cx.theme().colors().ghost_element_hover)
2320 } else {
2321 style
2322 }
2323 }
2324 })
2325 .on_drop(cx.listener(move |this, dragged_channel: &Channel, cx| {
2326 if dragged_channel.root_id() != root_id {
2327 return;
2328 }
2329 this.move_channel(dragged_channel.id, channel_id, cx);
2330 }))
2331 .child(
2332 ListItem::new(channel_id as usize)
2333 // Add one level of depth for the disclosure arrow.
2334 .indent_level(depth + 1)
2335 .indent_step_size(px(20.))
2336 .selected(is_selected || is_active)
2337 .toggle(disclosed)
2338 .on_toggle(
2339 cx.listener(move |this, _, cx| {
2340 this.toggle_channel_collapsed(channel_id, cx)
2341 }),
2342 )
2343 .on_click(cx.listener(move |this, _, cx| {
2344 if is_active {
2345 this.open_channel_notes(channel_id, cx)
2346 } else {
2347 this.join_channel(channel_id, cx)
2348 }
2349 }))
2350 .on_secondary_mouse_down(cx.listener(
2351 move |this, event: &MouseDownEvent, cx| {
2352 this.deploy_channel_context_menu(event.position, channel_id, ix, cx)
2353 },
2354 ))
2355 .start_slot(
2356 Icon::new(if is_public {
2357 IconName::Public
2358 } else {
2359 IconName::Hash
2360 })
2361 .size(IconSize::Small)
2362 .color(Color::Muted),
2363 )
2364 .child(
2365 h_flex()
2366 .id(channel_id as usize)
2367 .child(Label::new(channel.name.clone()))
2368 .children(face_pile.map(|face_pile| face_pile.p_1())),
2369 ),
2370 )
2371 .child(
2372 h_flex()
2373 .absolute()
2374 .right(rems(0.))
2375 .z_index(1)
2376 .h_full()
2377 .child(
2378 h_flex()
2379 .h_full()
2380 .gap_1()
2381 .px_1()
2382 .child(
2383 IconButton::new("channel_chat", IconName::MessageBubbles)
2384 .style(ButtonStyle::Filled)
2385 .shape(ui::IconButtonShape::Square)
2386 .icon_size(IconSize::Small)
2387 .icon_color(if has_messages_notification {
2388 Color::Default
2389 } else {
2390 Color::Muted
2391 })
2392 .on_click(cx.listener(move |this, _, cx| {
2393 this.join_channel_chat(channel_id, cx)
2394 }))
2395 .tooltip(|cx| Tooltip::text("Open channel chat", cx))
2396 .when(!has_messages_notification, |this| {
2397 this.visible_on_hover("")
2398 }),
2399 )
2400 .child(
2401 IconButton::new("channel_notes", IconName::File)
2402 .style(ButtonStyle::Filled)
2403 .shape(ui::IconButtonShape::Square)
2404 .icon_size(IconSize::Small)
2405 .icon_color(if has_notes_notification {
2406 Color::Default
2407 } else {
2408 Color::Muted
2409 })
2410 .on_click(cx.listener(move |this, _, cx| {
2411 this.open_channel_notes(channel_id, cx)
2412 }))
2413 .tooltip(|cx| Tooltip::text("Open channel notes", cx))
2414 .when(!has_notes_notification, |this| {
2415 this.visible_on_hover("")
2416 }),
2417 ),
2418 ),
2419 )
2420 .tooltip(|cx| Tooltip::text("Join channel", cx))
2421 }
2422
2423 fn render_channel_editor(&self, depth: usize, _cx: &mut ViewContext<Self>) -> impl IntoElement {
2424 let item = ListItem::new("channel-editor")
2425 .inset(false)
2426 // Add one level of depth for the disclosure arrow.
2427 .indent_level(depth + 1)
2428 .indent_step_size(px(20.))
2429 .start_slot(
2430 Icon::new(IconName::Hash)
2431 .size(IconSize::Small)
2432 .color(Color::Muted),
2433 );
2434
2435 if let Some(pending_name) = self
2436 .channel_editing_state
2437 .as_ref()
2438 .and_then(|state| state.pending_name())
2439 {
2440 item.child(Label::new(pending_name))
2441 } else {
2442 item.child(self.channel_name_editor.clone())
2443 }
2444 }
2445}
2446
2447fn render_tree_branch(is_last: bool, overdraw: bool, cx: &mut WindowContext) -> impl IntoElement {
2448 let rem_size = cx.rem_size();
2449 let line_height = cx.text_style().line_height_in_pixels(rem_size);
2450 let width = rem_size * 1.5;
2451 let thickness = px(1.);
2452 let color = cx.theme().colors().text;
2453
2454 canvas(move |bounds, cx| {
2455 let start_x = (bounds.left() + bounds.right() - thickness) / 2.;
2456 let start_y = (bounds.top() + bounds.bottom() - thickness) / 2.;
2457 let right = bounds.right();
2458 let top = bounds.top();
2459
2460 cx.paint_quad(fill(
2461 Bounds::from_corners(
2462 point(start_x, top),
2463 point(
2464 start_x + thickness,
2465 if is_last {
2466 start_y
2467 } else {
2468 bounds.bottom() + if overdraw { px(1.) } else { px(0.) }
2469 },
2470 ),
2471 ),
2472 color,
2473 ));
2474 cx.paint_quad(fill(
2475 Bounds::from_corners(point(start_x, start_y), point(right, start_y + thickness)),
2476 color,
2477 ));
2478 })
2479 .w(width)
2480 .h(line_height)
2481}
2482
2483impl Render for CollabPanel {
2484 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
2485 v_flex()
2486 .key_context("CollabPanel")
2487 .on_action(cx.listener(CollabPanel::cancel))
2488 .on_action(cx.listener(CollabPanel::select_next))
2489 .on_action(cx.listener(CollabPanel::select_prev))
2490 .on_action(cx.listener(CollabPanel::confirm))
2491 .on_action(cx.listener(CollabPanel::insert_space))
2492 .on_action(cx.listener(CollabPanel::remove_selected_channel))
2493 .on_action(cx.listener(CollabPanel::show_inline_context_menu))
2494 .on_action(cx.listener(CollabPanel::rename_selected_channel))
2495 .on_action(cx.listener(CollabPanel::collapse_selected_channel))
2496 .on_action(cx.listener(CollabPanel::expand_selected_channel))
2497 .on_action(cx.listener(CollabPanel::start_move_selected_channel))
2498 .track_focus(&self.focus_handle)
2499 .size_full()
2500 .child(if self.user_store.read(cx).current_user().is_none() {
2501 self.render_signed_out(cx)
2502 } else {
2503 self.render_signed_in(cx)
2504 })
2505 .children(self.context_menu.as_ref().map(|(menu, position, _)| {
2506 overlay()
2507 .position(*position)
2508 .anchor(gpui::AnchorCorner::TopLeft)
2509 .child(menu.clone())
2510 }))
2511 }
2512}
2513
2514impl EventEmitter<PanelEvent> for CollabPanel {}
2515
2516impl Panel for CollabPanel {
2517 fn position(&self, cx: &gpui::WindowContext) -> DockPosition {
2518 CollaborationPanelSettings::get_global(cx).dock
2519 }
2520
2521 fn position_is_valid(&self, position: DockPosition) -> bool {
2522 matches!(position, DockPosition::Left | DockPosition::Right)
2523 }
2524
2525 fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
2526 settings::update_settings_file::<CollaborationPanelSettings>(
2527 self.fs.clone(),
2528 cx,
2529 move |settings| settings.dock = Some(position),
2530 );
2531 }
2532
2533 fn size(&self, cx: &gpui::WindowContext) -> Pixels {
2534 self.width
2535 .unwrap_or_else(|| CollaborationPanelSettings::get_global(cx).default_width)
2536 }
2537
2538 fn set_size(&mut self, size: Option<Pixels>, cx: &mut ViewContext<Self>) {
2539 self.width = size;
2540 self.serialize(cx);
2541 cx.notify();
2542 }
2543
2544 fn icon(&self, cx: &gpui::WindowContext) -> Option<ui::IconName> {
2545 CollaborationPanelSettings::get_global(cx)
2546 .button
2547 .then(|| ui::IconName::Collab)
2548 }
2549
2550 fn icon_tooltip(&self, _cx: &WindowContext) -> Option<&'static str> {
2551 Some("Collab Panel")
2552 }
2553
2554 fn toggle_action(&self) -> Box<dyn gpui::Action> {
2555 Box::new(ToggleFocus)
2556 }
2557
2558 fn persistent_name() -> &'static str {
2559 "CollabPanel"
2560 }
2561}
2562
2563impl FocusableView for CollabPanel {
2564 fn focus_handle(&self, cx: &AppContext) -> gpui::FocusHandle {
2565 self.filter_editor.focus_handle(cx).clone()
2566 }
2567}
2568
2569impl PartialEq for ListEntry {
2570 fn eq(&self, other: &Self) -> bool {
2571 match self {
2572 ListEntry::Header(section_1) => {
2573 if let ListEntry::Header(section_2) = other {
2574 return section_1 == section_2;
2575 }
2576 }
2577 ListEntry::CallParticipant { user: user_1, .. } => {
2578 if let ListEntry::CallParticipant { user: user_2, .. } = other {
2579 return user_1.id == user_2.id;
2580 }
2581 }
2582 ListEntry::ParticipantProject {
2583 project_id: project_id_1,
2584 ..
2585 } => {
2586 if let ListEntry::ParticipantProject {
2587 project_id: project_id_2,
2588 ..
2589 } = other
2590 {
2591 return project_id_1 == project_id_2;
2592 }
2593 }
2594 ListEntry::ParticipantScreen {
2595 peer_id: peer_id_1, ..
2596 } => {
2597 if let ListEntry::ParticipantScreen {
2598 peer_id: peer_id_2, ..
2599 } = other
2600 {
2601 return peer_id_1 == peer_id_2;
2602 }
2603 }
2604 ListEntry::Channel {
2605 channel: channel_1, ..
2606 } => {
2607 if let ListEntry::Channel {
2608 channel: channel_2, ..
2609 } = other
2610 {
2611 return channel_1.id == channel_2.id;
2612 }
2613 }
2614 ListEntry::ChannelNotes { channel_id } => {
2615 if let ListEntry::ChannelNotes {
2616 channel_id: other_id,
2617 } = other
2618 {
2619 return channel_id == other_id;
2620 }
2621 }
2622 ListEntry::ChannelChat { channel_id } => {
2623 if let ListEntry::ChannelChat {
2624 channel_id: other_id,
2625 } = other
2626 {
2627 return channel_id == other_id;
2628 }
2629 }
2630 ListEntry::ChannelInvite(channel_1) => {
2631 if let ListEntry::ChannelInvite(channel_2) = other {
2632 return channel_1.id == channel_2.id;
2633 }
2634 }
2635 ListEntry::IncomingRequest(user_1) => {
2636 if let ListEntry::IncomingRequest(user_2) = other {
2637 return user_1.id == user_2.id;
2638 }
2639 }
2640 ListEntry::OutgoingRequest(user_1) => {
2641 if let ListEntry::OutgoingRequest(user_2) = other {
2642 return user_1.id == user_2.id;
2643 }
2644 }
2645 ListEntry::Contact {
2646 contact: contact_1, ..
2647 } => {
2648 if let ListEntry::Contact {
2649 contact: contact_2, ..
2650 } = other
2651 {
2652 return contact_1.user.id == contact_2.user.id;
2653 }
2654 }
2655 ListEntry::ChannelEditor { depth } => {
2656 if let ListEntry::ChannelEditor { depth: other_depth } = other {
2657 return depth == other_depth;
2658 }
2659 }
2660 ListEntry::ContactPlaceholder => {
2661 if let ListEntry::ContactPlaceholder = other {
2662 return true;
2663 }
2664 }
2665 }
2666 false
2667 }
2668}
2669
2670struct DraggedChannelView {
2671 channel: Channel,
2672 width: Pixels,
2673}
2674
2675impl Render for DraggedChannelView {
2676 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl Element {
2677 let ui_font = ThemeSettings::get_global(cx).ui_font.family.clone();
2678 h_flex()
2679 .font(ui_font)
2680 .bg(cx.theme().colors().background)
2681 .w(self.width)
2682 .p_1()
2683 .gap_1()
2684 .child(
2685 Icon::new(
2686 if self.channel.visibility == proto::ChannelVisibility::Public {
2687 IconName::Public
2688 } else {
2689 IconName::Hash
2690 },
2691 )
2692 .size(IconSize::Small)
2693 .color(Color::Muted),
2694 )
2695 .child(Label::new(self.channel.name.clone()))
2696 }
2697}