
This PR absolutely positions the channel buttons on top of the channels. This prevents the buttons from getting pushed off the edge of the panel when the channel names are long. Still needs some fine-tuning, but gets us closer to where we want to be. Release Notes: - N/A
2493 lines
93 KiB
Rust
2493 lines
93 KiB
Rust
mod channel_modal;
|
|
mod contact_finder;
|
|
|
|
use self::channel_modal::ChannelModal;
|
|
use crate::{
|
|
channel_view::ChannelView, chat_panel::ChatPanel, face_pile::FacePile,
|
|
CollaborationPanelSettings,
|
|
};
|
|
use call::ActiveCall;
|
|
use channel::{Channel, ChannelEvent, ChannelId, ChannelStore};
|
|
use client::{Client, Contact, User, UserStore};
|
|
use contact_finder::ContactFinder;
|
|
use db::kvp::KEY_VALUE_STORE;
|
|
use editor::Editor;
|
|
use feature_flags::{ChannelsAlpha, FeatureFlagAppExt, FeatureFlagViewExt};
|
|
use fuzzy::{match_strings, StringMatchCandidate};
|
|
use gpui::{
|
|
actions, canvas, div, fill, list, overlay, point, prelude::*, px, serde_json, AnyElement,
|
|
AppContext, AsyncWindowContext, Bounds, ClipboardItem, DismissEvent, Div, EventEmitter,
|
|
FocusHandle, FocusableView, InteractiveElement, IntoElement, ListOffset, ListState, Model,
|
|
MouseDownEvent, ParentElement, Pixels, Point, PromptLevel, Render, RenderOnce, SharedString,
|
|
Styled, Subscription, Task, View, ViewContext, VisualContext, WeakView,
|
|
};
|
|
use menu::{Cancel, Confirm, SelectNext, SelectPrev};
|
|
use project::{Fs, Project};
|
|
use rpc::proto::{self, PeerId};
|
|
use serde_derive::{Deserialize, Serialize};
|
|
use settings::{Settings, SettingsStore};
|
|
use smallvec::SmallVec;
|
|
use std::{mem, sync::Arc};
|
|
use theme::{ActiveTheme, ThemeSettings};
|
|
use ui::prelude::*;
|
|
use ui::{
|
|
h_stack, v_stack, Avatar, Button, Color, ContextMenu, Icon, IconButton, IconElement, IconSize,
|
|
Label, ListHeader, ListItem, Tooltip,
|
|
};
|
|
use util::{maybe, ResultExt, TryFutureExt};
|
|
use workspace::{
|
|
dock::{DockPosition, Panel, PanelEvent},
|
|
notifications::NotifyResultExt,
|
|
Workspace,
|
|
};
|
|
|
|
actions!(
|
|
collab_panel,
|
|
[
|
|
ToggleFocus,
|
|
Remove,
|
|
Secondary,
|
|
CollapseSelectedChannel,
|
|
ExpandSelectedChannel,
|
|
StartMoveChannel,
|
|
MoveSelected,
|
|
InsertSpace,
|
|
]
|
|
);
|
|
|
|
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
|
|
struct ChannelMoveClipboard {
|
|
channel_id: ChannelId,
|
|
}
|
|
|
|
const COLLABORATION_PANEL_KEY: &'static str = "CollaborationPanel";
|
|
|
|
pub fn init(cx: &mut AppContext) {
|
|
cx.observe_new_views(|workspace: &mut Workspace, _| {
|
|
workspace.register_action(|workspace, _: &ToggleFocus, cx| {
|
|
workspace.toggle_panel_focus::<CollabPanel>(cx);
|
|
});
|
|
})
|
|
.detach();
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub enum ChannelEditingState {
|
|
Create {
|
|
location: Option<ChannelId>,
|
|
pending_name: Option<String>,
|
|
},
|
|
Rename {
|
|
location: ChannelId,
|
|
pending_name: Option<String>,
|
|
},
|
|
}
|
|
|
|
impl ChannelEditingState {
|
|
fn pending_name(&self) -> Option<String> {
|
|
match self {
|
|
ChannelEditingState::Create { pending_name, .. } => pending_name.clone(),
|
|
ChannelEditingState::Rename { pending_name, .. } => pending_name.clone(),
|
|
}
|
|
}
|
|
}
|
|
|
|
pub struct CollabPanel {
|
|
width: Option<Pixels>,
|
|
fs: Arc<dyn Fs>,
|
|
focus_handle: FocusHandle,
|
|
channel_clipboard: Option<ChannelMoveClipboard>,
|
|
pending_serialization: Task<Option<()>>,
|
|
context_menu: Option<(View<ContextMenu>, Point<Pixels>, Subscription)>,
|
|
list_state: ListState,
|
|
filter_editor: View<Editor>,
|
|
channel_name_editor: View<Editor>,
|
|
channel_editing_state: Option<ChannelEditingState>,
|
|
entries: Vec<ListEntry>,
|
|
selection: Option<usize>,
|
|
channel_store: Model<ChannelStore>,
|
|
user_store: Model<UserStore>,
|
|
client: Arc<Client>,
|
|
project: Model<Project>,
|
|
match_candidates: Vec<StringMatchCandidate>,
|
|
subscriptions: Vec<Subscription>,
|
|
collapsed_sections: Vec<Section>,
|
|
collapsed_channels: Vec<ChannelId>,
|
|
workspace: WeakView<Workspace>,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize)]
|
|
struct SerializedCollabPanel {
|
|
width: Option<Pixels>,
|
|
collapsed_channels: Option<Vec<u64>>,
|
|
}
|
|
|
|
#[derive(Clone, Copy, PartialEq, Eq, Debug, PartialOrd, Ord)]
|
|
enum Section {
|
|
ActiveCall,
|
|
Channels,
|
|
ChannelInvites,
|
|
ContactRequests,
|
|
Contacts,
|
|
Online,
|
|
Offline,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
enum ListEntry {
|
|
Header(Section),
|
|
CallParticipant {
|
|
user: Arc<User>,
|
|
peer_id: Option<PeerId>,
|
|
is_pending: bool,
|
|
},
|
|
ParticipantProject {
|
|
project_id: u64,
|
|
worktree_root_names: Vec<String>,
|
|
host_user_id: u64,
|
|
is_last: bool,
|
|
},
|
|
ParticipantScreen {
|
|
peer_id: Option<PeerId>,
|
|
is_last: bool,
|
|
},
|
|
IncomingRequest(Arc<User>),
|
|
OutgoingRequest(Arc<User>),
|
|
ChannelInvite(Arc<Channel>),
|
|
Channel {
|
|
channel: Arc<Channel>,
|
|
depth: usize,
|
|
has_children: bool,
|
|
},
|
|
ChannelNotes {
|
|
channel_id: ChannelId,
|
|
},
|
|
ChannelChat {
|
|
channel_id: ChannelId,
|
|
},
|
|
ChannelEditor {
|
|
depth: usize,
|
|
},
|
|
Contact {
|
|
contact: Arc<Contact>,
|
|
calling: bool,
|
|
},
|
|
ContactPlaceholder,
|
|
}
|
|
|
|
impl CollabPanel {
|
|
pub fn new(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) -> View<Self> {
|
|
cx.new_view(|cx| {
|
|
let filter_editor = cx.new_view(|cx| {
|
|
let mut editor = Editor::single_line(cx);
|
|
editor.set_placeholder_text("Filter...", cx);
|
|
editor
|
|
});
|
|
|
|
cx.subscribe(&filter_editor, |this: &mut Self, _, event, cx| {
|
|
if let editor::EditorEvent::BufferEdited = event {
|
|
let query = this.filter_editor.read(cx).text(cx);
|
|
if !query.is_empty() {
|
|
this.selection.take();
|
|
}
|
|
this.update_entries(true, cx);
|
|
if !query.is_empty() {
|
|
this.selection = this
|
|
.entries
|
|
.iter()
|
|
.position(|entry| !matches!(entry, ListEntry::Header(_)));
|
|
}
|
|
}
|
|
})
|
|
.detach();
|
|
|
|
let channel_name_editor = cx.new_view(|cx| Editor::single_line(cx));
|
|
|
|
cx.subscribe(&channel_name_editor, |this: &mut Self, _, event, cx| {
|
|
if let editor::EditorEvent::Blurred = event {
|
|
if let Some(state) = &this.channel_editing_state {
|
|
if state.pending_name().is_some() {
|
|
return;
|
|
}
|
|
}
|
|
this.take_editing_state(cx);
|
|
this.update_entries(false, cx);
|
|
cx.notify();
|
|
}
|
|
})
|
|
.detach();
|
|
|
|
let view = cx.view().downgrade();
|
|
let list_state =
|
|
ListState::new(0, gpui::ListAlignment::Top, px(1000.), move |ix, cx| {
|
|
if let Some(view) = view.upgrade() {
|
|
view.update(cx, |view, cx| view.render_list_entry(ix, cx))
|
|
} else {
|
|
div().into_any()
|
|
}
|
|
});
|
|
|
|
let mut this = Self {
|
|
width: None,
|
|
focus_handle: cx.focus_handle(),
|
|
channel_clipboard: None,
|
|
fs: workspace.app_state().fs.clone(),
|
|
pending_serialization: Task::ready(None),
|
|
context_menu: None,
|
|
list_state,
|
|
channel_name_editor,
|
|
filter_editor,
|
|
entries: Vec::default(),
|
|
channel_editing_state: None,
|
|
selection: None,
|
|
channel_store: ChannelStore::global(cx),
|
|
user_store: workspace.user_store().clone(),
|
|
project: workspace.project().clone(),
|
|
subscriptions: Vec::default(),
|
|
match_candidates: Vec::default(),
|
|
collapsed_sections: vec![Section::Offline],
|
|
collapsed_channels: Vec::default(),
|
|
workspace: workspace.weak_handle(),
|
|
client: workspace.app_state().client.clone(),
|
|
};
|
|
|
|
this.update_entries(false, cx);
|
|
|
|
// Update the dock position when the setting changes.
|
|
let mut old_dock_position = this.position(cx);
|
|
this.subscriptions.push(cx.observe_global::<SettingsStore>(
|
|
move |this: &mut Self, cx| {
|
|
let new_dock_position = this.position(cx);
|
|
if new_dock_position != old_dock_position {
|
|
old_dock_position = new_dock_position;
|
|
cx.emit(PanelEvent::ChangePosition);
|
|
}
|
|
cx.notify();
|
|
},
|
|
));
|
|
|
|
let active_call = ActiveCall::global(cx);
|
|
this.subscriptions
|
|
.push(cx.observe(&this.user_store, |this, _, cx| {
|
|
this.update_entries(true, cx)
|
|
}));
|
|
this.subscriptions
|
|
.push(cx.observe(&this.channel_store, |this, _, cx| {
|
|
this.update_entries(true, cx)
|
|
}));
|
|
this.subscriptions
|
|
.push(cx.observe(&active_call, |this, _, cx| this.update_entries(true, cx)));
|
|
this.subscriptions
|
|
.push(cx.observe_flag::<ChannelsAlpha, _>(move |_, this, cx| {
|
|
this.update_entries(true, cx)
|
|
}));
|
|
this.subscriptions.push(cx.subscribe(
|
|
&this.channel_store,
|
|
|this, _channel_store, e, cx| match e {
|
|
ChannelEvent::ChannelCreated(channel_id)
|
|
| ChannelEvent::ChannelRenamed(channel_id) => {
|
|
if this.take_editing_state(cx) {
|
|
this.update_entries(false, cx);
|
|
this.selection = this.entries.iter().position(|entry| {
|
|
if let ListEntry::Channel { channel, .. } = entry {
|
|
channel.id == *channel_id
|
|
} else {
|
|
false
|
|
}
|
|
});
|
|
}
|
|
}
|
|
},
|
|
));
|
|
|
|
this
|
|
})
|
|
}
|
|
|
|
pub async fn load(
|
|
workspace: WeakView<Workspace>,
|
|
mut cx: AsyncWindowContext,
|
|
) -> anyhow::Result<View<Self>> {
|
|
let serialized_panel = cx
|
|
.background_executor()
|
|
.spawn(async move { KEY_VALUE_STORE.read_kvp(COLLABORATION_PANEL_KEY) })
|
|
.await
|
|
.map_err(|_| anyhow::anyhow!("Failed to read collaboration panel from key value store"))
|
|
.log_err()
|
|
.flatten()
|
|
.map(|panel| serde_json::from_str::<SerializedCollabPanel>(&panel))
|
|
.transpose()
|
|
.log_err()
|
|
.flatten();
|
|
|
|
workspace.update(&mut cx, |workspace, cx| {
|
|
let panel = CollabPanel::new(workspace, cx);
|
|
if let Some(serialized_panel) = serialized_panel {
|
|
panel.update(cx, |panel, cx| {
|
|
panel.width = serialized_panel.width;
|
|
panel.collapsed_channels = serialized_panel
|
|
.collapsed_channels
|
|
.unwrap_or_else(|| Vec::new());
|
|
cx.notify();
|
|
});
|
|
}
|
|
panel
|
|
})
|
|
}
|
|
|
|
fn serialize(&mut self, cx: &mut ViewContext<Self>) {
|
|
let width = self.width;
|
|
let collapsed_channels = self.collapsed_channels.clone();
|
|
self.pending_serialization = cx.background_executor().spawn(
|
|
async move {
|
|
KEY_VALUE_STORE
|
|
.write_kvp(
|
|
COLLABORATION_PANEL_KEY.into(),
|
|
serde_json::to_string(&SerializedCollabPanel {
|
|
width,
|
|
collapsed_channels: Some(collapsed_channels),
|
|
})?,
|
|
)
|
|
.await?;
|
|
anyhow::Ok(())
|
|
}
|
|
.log_err(),
|
|
);
|
|
}
|
|
|
|
fn scroll_to_item(&mut self, ix: usize) {
|
|
self.list_state.scroll_to_reveal_item(ix)
|
|
}
|
|
|
|
fn update_entries(&mut self, select_same_item: bool, cx: &mut ViewContext<Self>) {
|
|
let channel_store = self.channel_store.read(cx);
|
|
let user_store = self.user_store.read(cx);
|
|
let query = self.filter_editor.read(cx).text(cx);
|
|
let executor = cx.background_executor().clone();
|
|
|
|
let prev_selected_entry = self.selection.and_then(|ix| self.entries.get(ix).cloned());
|
|
let old_entries = mem::take(&mut self.entries);
|
|
let mut scroll_to_top = false;
|
|
|
|
if let Some(room) = ActiveCall::global(cx).read(cx).room() {
|
|
self.entries.push(ListEntry::Header(Section::ActiveCall));
|
|
if !old_entries
|
|
.iter()
|
|
.any(|entry| matches!(entry, ListEntry::Header(Section::ActiveCall)))
|
|
{
|
|
scroll_to_top = true;
|
|
}
|
|
|
|
if !self.collapsed_sections.contains(&Section::ActiveCall) {
|
|
let room = room.read(cx);
|
|
|
|
if let Some(channel_id) = room.channel_id() {
|
|
self.entries.push(ListEntry::ChannelNotes { channel_id });
|
|
self.entries.push(ListEntry::ChannelChat { channel_id })
|
|
}
|
|
|
|
// Populate the active user.
|
|
if let Some(user) = user_store.current_user() {
|
|
self.match_candidates.clear();
|
|
self.match_candidates.push(StringMatchCandidate {
|
|
id: 0,
|
|
string: user.github_login.clone(),
|
|
char_bag: user.github_login.chars().collect(),
|
|
});
|
|
let matches = executor.block(match_strings(
|
|
&self.match_candidates,
|
|
&query,
|
|
true,
|
|
usize::MAX,
|
|
&Default::default(),
|
|
executor.clone(),
|
|
));
|
|
if !matches.is_empty() {
|
|
let user_id = user.id;
|
|
self.entries.push(ListEntry::CallParticipant {
|
|
user,
|
|
peer_id: None,
|
|
is_pending: false,
|
|
});
|
|
let mut projects = room.local_participant().projects.iter().peekable();
|
|
while let Some(project) = projects.next() {
|
|
self.entries.push(ListEntry::ParticipantProject {
|
|
project_id: project.id,
|
|
worktree_root_names: project.worktree_root_names.clone(),
|
|
host_user_id: user_id,
|
|
is_last: projects.peek().is_none() && !room.is_screen_sharing(),
|
|
});
|
|
}
|
|
if room.is_screen_sharing() {
|
|
self.entries.push(ListEntry::ParticipantScreen {
|
|
peer_id: None,
|
|
is_last: true,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Populate remote participants.
|
|
self.match_candidates.clear();
|
|
self.match_candidates
|
|
.extend(room.remote_participants().iter().map(|(_, participant)| {
|
|
StringMatchCandidate {
|
|
id: participant.user.id as usize,
|
|
string: participant.user.github_login.clone(),
|
|
char_bag: participant.user.github_login.chars().collect(),
|
|
}
|
|
}));
|
|
let matches = executor.block(match_strings(
|
|
&self.match_candidates,
|
|
&query,
|
|
true,
|
|
usize::MAX,
|
|
&Default::default(),
|
|
executor.clone(),
|
|
));
|
|
for mat in matches {
|
|
let user_id = mat.candidate_id as u64;
|
|
let participant = &room.remote_participants()[&user_id];
|
|
self.entries.push(ListEntry::CallParticipant {
|
|
user: participant.user.clone(),
|
|
peer_id: Some(participant.peer_id),
|
|
is_pending: false,
|
|
});
|
|
let mut projects = participant.projects.iter().peekable();
|
|
while let Some(project) = projects.next() {
|
|
self.entries.push(ListEntry::ParticipantProject {
|
|
project_id: project.id,
|
|
worktree_root_names: project.worktree_root_names.clone(),
|
|
host_user_id: participant.user.id,
|
|
is_last: projects.peek().is_none()
|
|
&& participant.video_tracks.is_empty(),
|
|
});
|
|
}
|
|
if !participant.video_tracks.is_empty() {
|
|
self.entries.push(ListEntry::ParticipantScreen {
|
|
peer_id: Some(participant.peer_id),
|
|
is_last: true,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Populate pending participants.
|
|
self.match_candidates.clear();
|
|
self.match_candidates
|
|
.extend(room.pending_participants().iter().enumerate().map(
|
|
|(id, participant)| StringMatchCandidate {
|
|
id,
|
|
string: participant.github_login.clone(),
|
|
char_bag: participant.github_login.chars().collect(),
|
|
},
|
|
));
|
|
let matches = executor.block(match_strings(
|
|
&self.match_candidates,
|
|
&query,
|
|
true,
|
|
usize::MAX,
|
|
&Default::default(),
|
|
executor.clone(),
|
|
));
|
|
self.entries
|
|
.extend(matches.iter().map(|mat| ListEntry::CallParticipant {
|
|
user: room.pending_participants()[mat.candidate_id].clone(),
|
|
peer_id: None,
|
|
is_pending: true,
|
|
}));
|
|
}
|
|
}
|
|
|
|
let mut request_entries = Vec::new();
|
|
|
|
if cx.has_flag::<ChannelsAlpha>() {
|
|
self.entries.push(ListEntry::Header(Section::Channels));
|
|
|
|
if channel_store.channel_count() > 0 || self.channel_editing_state.is_some() {
|
|
self.match_candidates.clear();
|
|
self.match_candidates
|
|
.extend(channel_store.ordered_channels().enumerate().map(
|
|
|(ix, (_, channel))| StringMatchCandidate {
|
|
id: ix,
|
|
string: channel.name.clone().into(),
|
|
char_bag: channel.name.chars().collect(),
|
|
},
|
|
));
|
|
let matches = executor.block(match_strings(
|
|
&self.match_candidates,
|
|
&query,
|
|
true,
|
|
usize::MAX,
|
|
&Default::default(),
|
|
executor.clone(),
|
|
));
|
|
if let Some(state) = &self.channel_editing_state {
|
|
if matches!(state, ChannelEditingState::Create { location: None, .. }) {
|
|
self.entries.push(ListEntry::ChannelEditor { depth: 0 });
|
|
}
|
|
}
|
|
let mut collapse_depth = None;
|
|
for mat in matches {
|
|
let channel = channel_store.channel_at_index(mat.candidate_id).unwrap();
|
|
let depth = channel.parent_path.len();
|
|
|
|
if collapse_depth.is_none() && self.is_channel_collapsed(channel.id) {
|
|
collapse_depth = Some(depth);
|
|
} else if let Some(collapsed_depth) = collapse_depth {
|
|
if depth > collapsed_depth {
|
|
continue;
|
|
}
|
|
if self.is_channel_collapsed(channel.id) {
|
|
collapse_depth = Some(depth);
|
|
} else {
|
|
collapse_depth = None;
|
|
}
|
|
}
|
|
|
|
let has_children = channel_store
|
|
.channel_at_index(mat.candidate_id + 1)
|
|
.map_or(false, |next_channel| {
|
|
next_channel.parent_path.ends_with(&[channel.id])
|
|
});
|
|
|
|
match &self.channel_editing_state {
|
|
Some(ChannelEditingState::Create {
|
|
location: parent_id,
|
|
..
|
|
}) if *parent_id == Some(channel.id) => {
|
|
self.entries.push(ListEntry::Channel {
|
|
channel: channel.clone(),
|
|
depth,
|
|
has_children: false,
|
|
});
|
|
self.entries
|
|
.push(ListEntry::ChannelEditor { depth: depth + 1 });
|
|
}
|
|
Some(ChannelEditingState::Rename {
|
|
location: parent_id,
|
|
..
|
|
}) if parent_id == &channel.id => {
|
|
self.entries.push(ListEntry::ChannelEditor { depth });
|
|
}
|
|
_ => {
|
|
self.entries.push(ListEntry::Channel {
|
|
channel: channel.clone(),
|
|
depth,
|
|
has_children,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let channel_invites = channel_store.channel_invitations();
|
|
if !channel_invites.is_empty() {
|
|
self.match_candidates.clear();
|
|
self.match_candidates
|
|
.extend(channel_invites.iter().enumerate().map(|(ix, channel)| {
|
|
StringMatchCandidate {
|
|
id: ix,
|
|
string: channel.name.clone().into(),
|
|
char_bag: channel.name.chars().collect(),
|
|
}
|
|
}));
|
|
let matches = executor.block(match_strings(
|
|
&self.match_candidates,
|
|
&query,
|
|
true,
|
|
usize::MAX,
|
|
&Default::default(),
|
|
executor.clone(),
|
|
));
|
|
request_entries.extend(matches.iter().map(|mat| {
|
|
ListEntry::ChannelInvite(channel_invites[mat.candidate_id].clone())
|
|
}));
|
|
|
|
if !request_entries.is_empty() {
|
|
self.entries
|
|
.push(ListEntry::Header(Section::ChannelInvites));
|
|
if !self.collapsed_sections.contains(&Section::ChannelInvites) {
|
|
self.entries.append(&mut request_entries);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
self.entries.push(ListEntry::Header(Section::Contacts));
|
|
|
|
request_entries.clear();
|
|
let incoming = user_store.incoming_contact_requests();
|
|
if !incoming.is_empty() {
|
|
self.match_candidates.clear();
|
|
self.match_candidates
|
|
.extend(
|
|
incoming
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(ix, user)| StringMatchCandidate {
|
|
id: ix,
|
|
string: user.github_login.clone(),
|
|
char_bag: user.github_login.chars().collect(),
|
|
}),
|
|
);
|
|
let matches = executor.block(match_strings(
|
|
&self.match_candidates,
|
|
&query,
|
|
true,
|
|
usize::MAX,
|
|
&Default::default(),
|
|
executor.clone(),
|
|
));
|
|
request_entries.extend(
|
|
matches
|
|
.iter()
|
|
.map(|mat| ListEntry::IncomingRequest(incoming[mat.candidate_id].clone())),
|
|
);
|
|
}
|
|
|
|
let outgoing = user_store.outgoing_contact_requests();
|
|
if !outgoing.is_empty() {
|
|
self.match_candidates.clear();
|
|
self.match_candidates
|
|
.extend(
|
|
outgoing
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(ix, user)| StringMatchCandidate {
|
|
id: ix,
|
|
string: user.github_login.clone(),
|
|
char_bag: user.github_login.chars().collect(),
|
|
}),
|
|
);
|
|
let matches = executor.block(match_strings(
|
|
&self.match_candidates,
|
|
&query,
|
|
true,
|
|
usize::MAX,
|
|
&Default::default(),
|
|
executor.clone(),
|
|
));
|
|
request_entries.extend(
|
|
matches
|
|
.iter()
|
|
.map(|mat| ListEntry::OutgoingRequest(outgoing[mat.candidate_id].clone())),
|
|
);
|
|
}
|
|
|
|
if !request_entries.is_empty() {
|
|
self.entries
|
|
.push(ListEntry::Header(Section::ContactRequests));
|
|
if !self.collapsed_sections.contains(&Section::ContactRequests) {
|
|
self.entries.append(&mut request_entries);
|
|
}
|
|
}
|
|
|
|
let contacts = user_store.contacts();
|
|
if !contacts.is_empty() {
|
|
self.match_candidates.clear();
|
|
self.match_candidates
|
|
.extend(
|
|
contacts
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(ix, contact)| StringMatchCandidate {
|
|
id: ix,
|
|
string: contact.user.github_login.clone(),
|
|
char_bag: contact.user.github_login.chars().collect(),
|
|
}),
|
|
);
|
|
|
|
let matches = executor.block(match_strings(
|
|
&self.match_candidates,
|
|
&query,
|
|
true,
|
|
usize::MAX,
|
|
&Default::default(),
|
|
executor.clone(),
|
|
));
|
|
|
|
let (online_contacts, offline_contacts) = matches
|
|
.iter()
|
|
.partition::<Vec<_>, _>(|mat| contacts[mat.candidate_id].online);
|
|
|
|
for (matches, section) in [
|
|
(online_contacts, Section::Online),
|
|
(offline_contacts, Section::Offline),
|
|
] {
|
|
if !matches.is_empty() {
|
|
self.entries.push(ListEntry::Header(section));
|
|
if !self.collapsed_sections.contains(§ion) {
|
|
let active_call = &ActiveCall::global(cx).read(cx);
|
|
for mat in matches {
|
|
let contact = &contacts[mat.candidate_id];
|
|
self.entries.push(ListEntry::Contact {
|
|
contact: contact.clone(),
|
|
calling: active_call.pending_invites().contains(&contact.user.id),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if incoming.is_empty() && outgoing.is_empty() && contacts.is_empty() {
|
|
self.entries.push(ListEntry::ContactPlaceholder);
|
|
}
|
|
|
|
if select_same_item {
|
|
if let Some(prev_selected_entry) = prev_selected_entry {
|
|
self.selection.take();
|
|
for (ix, entry) in self.entries.iter().enumerate() {
|
|
if *entry == prev_selected_entry {
|
|
self.selection = Some(ix);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
self.selection = self.selection.and_then(|prev_selection| {
|
|
if self.entries.is_empty() {
|
|
None
|
|
} else {
|
|
Some(prev_selection.min(self.entries.len() - 1))
|
|
}
|
|
});
|
|
}
|
|
|
|
let old_scroll_top = self.list_state.logical_scroll_top();
|
|
self.list_state.reset(self.entries.len());
|
|
|
|
if scroll_to_top {
|
|
self.list_state.scroll_to(ListOffset::default());
|
|
} else {
|
|
// Attempt to maintain the same scroll position.
|
|
if let Some(old_top_entry) = old_entries.get(old_scroll_top.item_ix) {
|
|
let new_scroll_top = self
|
|
.entries
|
|
.iter()
|
|
.position(|entry| entry == old_top_entry)
|
|
.map(|item_ix| ListOffset {
|
|
item_ix,
|
|
offset_in_item: old_scroll_top.offset_in_item,
|
|
})
|
|
.or_else(|| {
|
|
let entry_after_old_top = old_entries.get(old_scroll_top.item_ix + 1)?;
|
|
let item_ix = self
|
|
.entries
|
|
.iter()
|
|
.position(|entry| entry == entry_after_old_top)?;
|
|
Some(ListOffset {
|
|
item_ix,
|
|
offset_in_item: Pixels::ZERO,
|
|
})
|
|
})
|
|
.or_else(|| {
|
|
let entry_before_old_top =
|
|
old_entries.get(old_scroll_top.item_ix.saturating_sub(1))?;
|
|
let item_ix = self
|
|
.entries
|
|
.iter()
|
|
.position(|entry| entry == entry_before_old_top)?;
|
|
Some(ListOffset {
|
|
item_ix,
|
|
offset_in_item: Pixels::ZERO,
|
|
})
|
|
});
|
|
|
|
self.list_state
|
|
.scroll_to(new_scroll_top.unwrap_or(old_scroll_top));
|
|
}
|
|
}
|
|
|
|
cx.notify();
|
|
}
|
|
|
|
fn render_call_participant(
|
|
&self,
|
|
user: &Arc<User>,
|
|
peer_id: Option<PeerId>,
|
|
is_pending: bool,
|
|
is_selected: bool,
|
|
cx: &mut ViewContext<Self>,
|
|
) -> ListItem {
|
|
let is_current_user =
|
|
self.user_store.read(cx).current_user().map(|user| user.id) == Some(user.id);
|
|
let tooltip = format!("Follow {}", user.github_login);
|
|
|
|
ListItem::new(SharedString::from(user.github_login.clone()))
|
|
.start_slot(Avatar::new(user.avatar_uri.clone()))
|
|
.child(Label::new(user.github_login.clone()))
|
|
.selected(is_selected)
|
|
.end_slot(if is_pending {
|
|
Label::new("Calling").color(Color::Muted).into_any_element()
|
|
} else if is_current_user {
|
|
IconButton::new("leave-call", Icon::Exit)
|
|
.style(ButtonStyle::Subtle)
|
|
.on_click(move |_, cx| Self::leave_call(cx))
|
|
.tooltip(|cx| Tooltip::text("Leave Call", cx))
|
|
.into_any_element()
|
|
} else {
|
|
div().into_any_element()
|
|
})
|
|
.when_some(peer_id, |this, peer_id| {
|
|
this.tooltip(move |cx| Tooltip::text(tooltip.clone(), cx))
|
|
.on_click(cx.listener(move |this, _, cx| {
|
|
this.workspace
|
|
.update(cx, |workspace, cx| workspace.follow(peer_id, cx))
|
|
.ok();
|
|
}))
|
|
})
|
|
}
|
|
|
|
fn render_participant_project(
|
|
&self,
|
|
project_id: u64,
|
|
worktree_root_names: &[String],
|
|
host_user_id: u64,
|
|
is_last: bool,
|
|
is_selected: bool,
|
|
cx: &mut ViewContext<Self>,
|
|
) -> impl IntoElement {
|
|
let project_name: SharedString = if worktree_root_names.is_empty() {
|
|
"untitled".to_string()
|
|
} else {
|
|
worktree_root_names.join(", ")
|
|
}
|
|
.into();
|
|
|
|
ListItem::new(project_id as usize)
|
|
.selected(is_selected)
|
|
.on_click(cx.listener(move |this, _, cx| {
|
|
this.workspace
|
|
.update(cx, |workspace, cx| {
|
|
let app_state = workspace.app_state().clone();
|
|
workspace::join_remote_project(project_id, host_user_id, app_state, cx)
|
|
.detach_and_log_err(cx);
|
|
})
|
|
.ok();
|
|
}))
|
|
.start_slot(
|
|
h_stack()
|
|
.gap_1()
|
|
.child(render_tree_branch(is_last, cx))
|
|
.child(IconButton::new(0, Icon::Folder)),
|
|
)
|
|
.child(Label::new(project_name.clone()))
|
|
.tooltip(move |cx| Tooltip::text(format!("Open {}", project_name), cx))
|
|
}
|
|
|
|
fn render_participant_screen(
|
|
&self,
|
|
peer_id: Option<PeerId>,
|
|
is_last: bool,
|
|
is_selected: bool,
|
|
cx: &mut ViewContext<Self>,
|
|
) -> impl IntoElement {
|
|
let id = peer_id.map_or(usize::MAX, |id| id.as_u64() as usize);
|
|
|
|
ListItem::new(("screen", id))
|
|
.selected(is_selected)
|
|
.start_slot(
|
|
h_stack()
|
|
.gap_1()
|
|
.child(render_tree_branch(is_last, cx))
|
|
.child(IconButton::new(0, Icon::Screen)),
|
|
)
|
|
.child(Label::new("Screen"))
|
|
.when_some(peer_id, |this, _| {
|
|
this.on_click(cx.listener(move |this, _, cx| {
|
|
this.workspace
|
|
.update(cx, |workspace, cx| {
|
|
workspace.open_shared_screen(peer_id.unwrap(), cx)
|
|
})
|
|
.ok();
|
|
}))
|
|
.tooltip(move |cx| Tooltip::text(format!("Open shared screen"), cx))
|
|
})
|
|
}
|
|
|
|
fn take_editing_state(&mut self, cx: &mut ViewContext<Self>) -> bool {
|
|
if let Some(_) = self.channel_editing_state.take() {
|
|
self.channel_name_editor.update(cx, |editor, cx| {
|
|
editor.set_text("", cx);
|
|
});
|
|
true
|
|
} else {
|
|
false
|
|
}
|
|
}
|
|
|
|
fn render_channel_notes(
|
|
&self,
|
|
channel_id: ChannelId,
|
|
is_selected: bool,
|
|
cx: &mut ViewContext<Self>,
|
|
) -> impl IntoElement {
|
|
ListItem::new("channel-notes")
|
|
.selected(is_selected)
|
|
.on_click(cx.listener(move |this, _, cx| {
|
|
this.open_channel_notes(channel_id, cx);
|
|
}))
|
|
.start_slot(
|
|
h_stack()
|
|
.gap_1()
|
|
.child(render_tree_branch(false, cx))
|
|
.child(IconButton::new(0, Icon::File)),
|
|
)
|
|
.child(div().h_7().w_full().child(Label::new("notes")))
|
|
.tooltip(move |cx| Tooltip::text("Open Channel Notes", cx))
|
|
}
|
|
|
|
fn render_channel_chat(
|
|
&self,
|
|
channel_id: ChannelId,
|
|
is_selected: bool,
|
|
cx: &mut ViewContext<Self>,
|
|
) -> impl IntoElement {
|
|
ListItem::new("channel-chat")
|
|
.selected(is_selected)
|
|
.on_click(cx.listener(move |this, _, cx| {
|
|
this.join_channel_chat(channel_id, cx);
|
|
}))
|
|
.start_slot(
|
|
h_stack()
|
|
.gap_1()
|
|
.child(render_tree_branch(false, cx))
|
|
.child(IconButton::new(0, Icon::MessageBubbles)),
|
|
)
|
|
.child(Label::new("chat"))
|
|
.tooltip(move |cx| Tooltip::text("Open Chat", cx))
|
|
}
|
|
|
|
fn has_subchannels(&self, ix: usize) -> bool {
|
|
self.entries.get(ix).map_or(false, |entry| {
|
|
if let ListEntry::Channel { has_children, .. } = entry {
|
|
*has_children
|
|
} else {
|
|
false
|
|
}
|
|
})
|
|
}
|
|
|
|
fn deploy_channel_context_menu(
|
|
&mut self,
|
|
position: Point<Pixels>,
|
|
channel_id: ChannelId,
|
|
ix: usize,
|
|
cx: &mut ViewContext<Self>,
|
|
) {
|
|
let clipboard_channel_name = self.channel_clipboard.as_ref().and_then(|clipboard| {
|
|
self.channel_store
|
|
.read(cx)
|
|
.channel_for_id(clipboard.channel_id)
|
|
.map(|channel| channel.name.clone())
|
|
});
|
|
let this = cx.view().clone();
|
|
|
|
let context_menu = ContextMenu::build(cx, |mut context_menu, cx| {
|
|
if self.has_subchannels(ix) {
|
|
let expand_action_name = if self.is_channel_collapsed(channel_id) {
|
|
"Expand Subchannels"
|
|
} else {
|
|
"Collapse Subchannels"
|
|
};
|
|
context_menu = context_menu.entry(
|
|
expand_action_name,
|
|
None,
|
|
cx.handler_for(&this, move |this, cx| {
|
|
this.toggle_channel_collapsed(channel_id, cx)
|
|
}),
|
|
);
|
|
}
|
|
|
|
context_menu = context_menu
|
|
.entry(
|
|
"Open Notes",
|
|
None,
|
|
cx.handler_for(&this, move |this, cx| {
|
|
this.open_channel_notes(channel_id, cx)
|
|
}),
|
|
)
|
|
.entry(
|
|
"Open Chat",
|
|
None,
|
|
cx.handler_for(&this, move |this, cx| {
|
|
this.join_channel_chat(channel_id, cx)
|
|
}),
|
|
)
|
|
.entry(
|
|
"Copy Channel Link",
|
|
None,
|
|
cx.handler_for(&this, move |this, cx| {
|
|
this.copy_channel_link(channel_id, cx)
|
|
}),
|
|
);
|
|
|
|
if self.channel_store.read(cx).is_channel_admin(channel_id) {
|
|
context_menu = context_menu
|
|
.separator()
|
|
.entry(
|
|
"New Subchannel",
|
|
None,
|
|
cx.handler_for(&this, move |this, cx| this.new_subchannel(channel_id, cx)),
|
|
)
|
|
.entry(
|
|
"Rename",
|
|
None,
|
|
cx.handler_for(&this, move |this, cx| this.rename_channel(channel_id, cx)),
|
|
)
|
|
.entry(
|
|
"Move this channel",
|
|
None,
|
|
cx.handler_for(&this, move |this, cx| {
|
|
this.start_move_channel(channel_id, cx)
|
|
}),
|
|
);
|
|
|
|
if let Some(channel_name) = clipboard_channel_name {
|
|
context_menu = context_menu.separator().entry(
|
|
format!("Move '#{}' here", channel_name),
|
|
None,
|
|
cx.handler_for(&this, move |this, cx| {
|
|
this.move_channel_on_clipboard(channel_id, cx)
|
|
}),
|
|
);
|
|
}
|
|
|
|
context_menu = context_menu
|
|
.separator()
|
|
.entry(
|
|
"Invite Members",
|
|
None,
|
|
cx.handler_for(&this, move |this, cx| this.invite_members(channel_id, cx)),
|
|
)
|
|
.entry(
|
|
"Manage Members",
|
|
None,
|
|
cx.handler_for(&this, move |this, cx| this.manage_members(channel_id, cx)),
|
|
)
|
|
.entry(
|
|
"Delete",
|
|
None,
|
|
cx.handler_for(&this, move |this, cx| this.remove_channel(channel_id, cx)),
|
|
);
|
|
}
|
|
|
|
context_menu
|
|
});
|
|
|
|
cx.focus_view(&context_menu);
|
|
let subscription =
|
|
cx.subscribe(&context_menu, |this, _, _: &DismissEvent, cx| {
|
|
if this.context_menu.as_ref().is_some_and(|context_menu| {
|
|
context_menu.0.focus_handle(cx).contains_focused(cx)
|
|
}) {
|
|
cx.focus_self();
|
|
}
|
|
this.context_menu.take();
|
|
cx.notify();
|
|
});
|
|
self.context_menu = Some((context_menu, position, subscription));
|
|
|
|
cx.notify();
|
|
}
|
|
|
|
fn cancel(&mut self, _: &Cancel, cx: &mut ViewContext<Self>) {
|
|
if self.take_editing_state(cx) {
|
|
cx.focus_view(&self.filter_editor);
|
|
} else {
|
|
self.filter_editor.update(cx, |editor, cx| {
|
|
if editor.buffer().read(cx).len(cx) > 0 {
|
|
editor.set_text("", cx);
|
|
}
|
|
});
|
|
}
|
|
|
|
self.update_entries(false, cx);
|
|
}
|
|
|
|
fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
|
|
let ix = self.selection.map_or(0, |ix| ix + 1);
|
|
if ix < self.entries.len() {
|
|
self.selection = Some(ix);
|
|
}
|
|
|
|
if let Some(ix) = self.selection {
|
|
self.scroll_to_item(ix)
|
|
}
|
|
cx.notify();
|
|
}
|
|
|
|
fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
|
|
let ix = self.selection.take().unwrap_or(0);
|
|
if ix > 0 {
|
|
self.selection = Some(ix - 1);
|
|
}
|
|
|
|
if let Some(ix) = self.selection {
|
|
self.scroll_to_item(ix)
|
|
}
|
|
cx.notify();
|
|
}
|
|
|
|
fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
|
|
if self.confirm_channel_edit(cx) {
|
|
return;
|
|
}
|
|
|
|
if let Some(selection) = self.selection {
|
|
if let Some(entry) = self.entries.get(selection) {
|
|
match entry {
|
|
ListEntry::Header(section) => match section {
|
|
Section::ActiveCall => Self::leave_call(cx),
|
|
Section::Channels => self.new_root_channel(cx),
|
|
Section::Contacts => self.toggle_contact_finder(cx),
|
|
Section::ContactRequests
|
|
| Section::Online
|
|
| Section::Offline
|
|
| Section::ChannelInvites => {
|
|
self.toggle_section_expanded(*section, cx);
|
|
}
|
|
},
|
|
ListEntry::Contact { contact, calling } => {
|
|
if contact.online && !contact.busy && !calling {
|
|
self.call(contact.user.id, cx);
|
|
}
|
|
}
|
|
ListEntry::ParticipantProject {
|
|
project_id,
|
|
host_user_id,
|
|
..
|
|
} => {
|
|
if let Some(workspace) = self.workspace.upgrade() {
|
|
let app_state = workspace.read(cx).app_state().clone();
|
|
workspace::join_remote_project(
|
|
*project_id,
|
|
*host_user_id,
|
|
app_state,
|
|
cx,
|
|
)
|
|
.detach_and_log_err(cx);
|
|
}
|
|
}
|
|
ListEntry::ParticipantScreen { peer_id, .. } => {
|
|
let Some(peer_id) = peer_id else {
|
|
return;
|
|
};
|
|
if let Some(workspace) = self.workspace.upgrade() {
|
|
workspace.update(cx, |workspace, cx| {
|
|
workspace.open_shared_screen(*peer_id, cx)
|
|
});
|
|
}
|
|
}
|
|
ListEntry::Channel { channel, .. } => {
|
|
let is_active = maybe!({
|
|
let call_channel = ActiveCall::global(cx)
|
|
.read(cx)
|
|
.room()?
|
|
.read(cx)
|
|
.channel_id()?;
|
|
|
|
Some(call_channel == channel.id)
|
|
})
|
|
.unwrap_or(false);
|
|
if is_active {
|
|
self.open_channel_notes(channel.id, cx)
|
|
} else {
|
|
self.join_channel(channel.id, cx)
|
|
}
|
|
}
|
|
ListEntry::ContactPlaceholder => self.toggle_contact_finder(cx),
|
|
ListEntry::CallParticipant { user, peer_id, .. } => {
|
|
if Some(user) == self.user_store.read(cx).current_user().as_ref() {
|
|
Self::leave_call(cx);
|
|
} else if let Some(peer_id) = peer_id {
|
|
self.workspace
|
|
.update(cx, |workspace, cx| workspace.follow(*peer_id, cx))
|
|
.ok();
|
|
}
|
|
}
|
|
ListEntry::IncomingRequest(user) => {
|
|
self.respond_to_contact_request(user.id, true, cx)
|
|
}
|
|
ListEntry::ChannelInvite(channel) => {
|
|
self.respond_to_channel_invite(channel.id, true, cx)
|
|
}
|
|
ListEntry::ChannelNotes { channel_id } => {
|
|
self.open_channel_notes(*channel_id, cx)
|
|
}
|
|
ListEntry::ChannelChat { channel_id } => {
|
|
self.join_channel_chat(*channel_id, cx)
|
|
}
|
|
|
|
ListEntry::OutgoingRequest(_) => {}
|
|
ListEntry::ChannelEditor { .. } => {}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn insert_space(&mut self, _: &InsertSpace, cx: &mut ViewContext<Self>) {
|
|
if self.channel_editing_state.is_some() {
|
|
self.channel_name_editor.update(cx, |editor, cx| {
|
|
editor.insert(" ", cx);
|
|
});
|
|
}
|
|
}
|
|
|
|
fn confirm_channel_edit(&mut self, cx: &mut ViewContext<CollabPanel>) -> bool {
|
|
if let Some(editing_state) = &mut self.channel_editing_state {
|
|
match editing_state {
|
|
ChannelEditingState::Create {
|
|
location,
|
|
pending_name,
|
|
..
|
|
} => {
|
|
if pending_name.is_some() {
|
|
return false;
|
|
}
|
|
let channel_name = self.channel_name_editor.read(cx).text(cx);
|
|
|
|
*pending_name = Some(channel_name.clone());
|
|
|
|
self.channel_store
|
|
.update(cx, |channel_store, cx| {
|
|
channel_store.create_channel(&channel_name, *location, cx)
|
|
})
|
|
.detach();
|
|
cx.notify();
|
|
}
|
|
ChannelEditingState::Rename {
|
|
location,
|
|
pending_name,
|
|
} => {
|
|
if pending_name.is_some() {
|
|
return false;
|
|
}
|
|
let channel_name = self.channel_name_editor.read(cx).text(cx);
|
|
*pending_name = Some(channel_name.clone());
|
|
|
|
self.channel_store
|
|
.update(cx, |channel_store, cx| {
|
|
channel_store.rename(*location, &channel_name, cx)
|
|
})
|
|
.detach();
|
|
cx.notify();
|
|
}
|
|
}
|
|
cx.focus_self();
|
|
true
|
|
} else {
|
|
false
|
|
}
|
|
}
|
|
|
|
fn toggle_section_expanded(&mut self, section: Section, cx: &mut ViewContext<Self>) {
|
|
if let Some(ix) = self.collapsed_sections.iter().position(|s| *s == section) {
|
|
self.collapsed_sections.remove(ix);
|
|
} else {
|
|
self.collapsed_sections.push(section);
|
|
}
|
|
self.update_entries(false, cx);
|
|
}
|
|
|
|
fn collapse_selected_channel(
|
|
&mut self,
|
|
_: &CollapseSelectedChannel,
|
|
cx: &mut ViewContext<Self>,
|
|
) {
|
|
let Some(channel_id) = self.selected_channel().map(|channel| channel.id) else {
|
|
return;
|
|
};
|
|
|
|
if self.is_channel_collapsed(channel_id) {
|
|
return;
|
|
}
|
|
|
|
self.toggle_channel_collapsed(channel_id, cx);
|
|
}
|
|
|
|
fn expand_selected_channel(&mut self, _: &ExpandSelectedChannel, cx: &mut ViewContext<Self>) {
|
|
let Some(id) = self.selected_channel().map(|channel| channel.id) else {
|
|
return;
|
|
};
|
|
|
|
if !self.is_channel_collapsed(id) {
|
|
return;
|
|
}
|
|
|
|
self.toggle_channel_collapsed(id, cx)
|
|
}
|
|
|
|
// fn toggle_channel_collapsed_action(
|
|
// &mut self,
|
|
// action: &ToggleCollapse,
|
|
// cx: &mut ViewContext<Self>,
|
|
// ) {
|
|
// self.toggle_channel_collapsed(action.location, cx);
|
|
// }
|
|
|
|
fn toggle_channel_collapsed<'a>(&mut self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
|
|
match self.collapsed_channels.binary_search(&channel_id) {
|
|
Ok(ix) => {
|
|
self.collapsed_channels.remove(ix);
|
|
}
|
|
Err(ix) => {
|
|
self.collapsed_channels.insert(ix, channel_id);
|
|
}
|
|
};
|
|
self.serialize(cx);
|
|
self.update_entries(true, cx);
|
|
cx.notify();
|
|
cx.focus_self();
|
|
}
|
|
|
|
fn is_channel_collapsed(&self, channel_id: ChannelId) -> bool {
|
|
self.collapsed_channels.binary_search(&channel_id).is_ok()
|
|
}
|
|
|
|
fn leave_call(cx: &mut WindowContext) {
|
|
ActiveCall::global(cx)
|
|
.update(cx, |call, cx| call.hang_up(cx))
|
|
.detach_and_log_err(cx);
|
|
}
|
|
|
|
fn toggle_contact_finder(&mut self, cx: &mut ViewContext<Self>) {
|
|
if let Some(workspace) = self.workspace.upgrade() {
|
|
workspace.update(cx, |workspace, cx| {
|
|
workspace.toggle_modal(cx, |cx| {
|
|
let mut finder = ContactFinder::new(self.user_store.clone(), cx);
|
|
finder.set_query(self.filter_editor.read(cx).text(cx), cx);
|
|
finder
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
fn new_root_channel(&mut self, cx: &mut ViewContext<Self>) {
|
|
self.channel_editing_state = Some(ChannelEditingState::Create {
|
|
location: None,
|
|
pending_name: None,
|
|
});
|
|
self.update_entries(false, cx);
|
|
self.select_channel_editor();
|
|
cx.focus_view(&self.channel_name_editor);
|
|
cx.notify();
|
|
}
|
|
|
|
fn select_channel_editor(&mut self) {
|
|
self.selection = self.entries.iter().position(|entry| match entry {
|
|
ListEntry::ChannelEditor { .. } => true,
|
|
_ => false,
|
|
});
|
|
}
|
|
|
|
fn new_subchannel(&mut self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
|
|
self.collapsed_channels
|
|
.retain(|channel| *channel != channel_id);
|
|
self.channel_editing_state = Some(ChannelEditingState::Create {
|
|
location: Some(channel_id),
|
|
pending_name: None,
|
|
});
|
|
self.update_entries(false, cx);
|
|
self.select_channel_editor();
|
|
cx.focus_view(&self.channel_name_editor);
|
|
cx.notify();
|
|
}
|
|
|
|
fn invite_members(&mut self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
|
|
self.show_channel_modal(channel_id, channel_modal::Mode::InviteMembers, cx);
|
|
}
|
|
|
|
fn manage_members(&mut self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
|
|
self.show_channel_modal(channel_id, channel_modal::Mode::ManageMembers, cx);
|
|
}
|
|
|
|
fn remove_selected_channel(&mut self, _: &Remove, cx: &mut ViewContext<Self>) {
|
|
if let Some(channel) = self.selected_channel() {
|
|
self.remove_channel(channel.id, cx)
|
|
}
|
|
}
|
|
|
|
fn rename_selected_channel(&mut self, _: &menu::SecondaryConfirm, cx: &mut ViewContext<Self>) {
|
|
if let Some(channel) = self.selected_channel() {
|
|
self.rename_channel(channel.id, cx);
|
|
}
|
|
}
|
|
|
|
fn rename_channel(&mut self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
|
|
let channel_store = self.channel_store.read(cx);
|
|
if !channel_store.is_channel_admin(channel_id) {
|
|
return;
|
|
}
|
|
if let Some(channel) = channel_store.channel_for_id(channel_id).cloned() {
|
|
self.channel_editing_state = Some(ChannelEditingState::Rename {
|
|
location: channel_id,
|
|
pending_name: None,
|
|
});
|
|
self.channel_name_editor.update(cx, |editor, cx| {
|
|
editor.set_text(channel.name.clone(), cx);
|
|
editor.select_all(&Default::default(), cx);
|
|
});
|
|
cx.focus_view(&self.channel_name_editor);
|
|
self.update_entries(false, cx);
|
|
self.select_channel_editor();
|
|
}
|
|
}
|
|
|
|
fn start_move_channel(&mut self, channel_id: ChannelId, _cx: &mut ViewContext<Self>) {
|
|
self.channel_clipboard = Some(ChannelMoveClipboard { channel_id });
|
|
}
|
|
|
|
fn start_move_selected_channel(&mut self, _: &StartMoveChannel, cx: &mut ViewContext<Self>) {
|
|
if let Some(channel) = self.selected_channel() {
|
|
self.start_move_channel(channel.id, cx);
|
|
}
|
|
}
|
|
|
|
fn move_channel_on_clipboard(
|
|
&mut self,
|
|
to_channel_id: ChannelId,
|
|
cx: &mut ViewContext<CollabPanel>,
|
|
) {
|
|
if let Some(clipboard) = self.channel_clipboard.take() {
|
|
self.channel_store.update(cx, |channel_store, cx| {
|
|
channel_store
|
|
.move_channel(clipboard.channel_id, Some(to_channel_id), cx)
|
|
.detach_and_log_err(cx)
|
|
})
|
|
}
|
|
}
|
|
|
|
fn open_channel_notes(&mut self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
|
|
if let Some(workspace) = self.workspace.upgrade() {
|
|
ChannelView::open(channel_id, workspace, cx).detach();
|
|
}
|
|
}
|
|
|
|
fn show_inline_context_menu(&mut self, _: &menu::ShowContextMenu, cx: &mut ViewContext<Self>) {
|
|
let Some(channel) = self.selected_channel() else {
|
|
return;
|
|
};
|
|
let Some(bounds) = self
|
|
.selection
|
|
.and_then(|ix| self.list_state.bounds_for_item(ix))
|
|
else {
|
|
return;
|
|
};
|
|
|
|
self.deploy_channel_context_menu(bounds.center(), channel.id, self.selection.unwrap(), cx);
|
|
cx.stop_propagation();
|
|
}
|
|
|
|
fn selected_channel(&self) -> Option<&Arc<Channel>> {
|
|
self.selection
|
|
.and_then(|ix| self.entries.get(ix))
|
|
.and_then(|entry| match entry {
|
|
ListEntry::Channel { channel, .. } => Some(channel),
|
|
_ => None,
|
|
})
|
|
}
|
|
|
|
fn show_channel_modal(
|
|
&mut self,
|
|
channel_id: ChannelId,
|
|
mode: channel_modal::Mode,
|
|
cx: &mut ViewContext<Self>,
|
|
) {
|
|
let workspace = self.workspace.clone();
|
|
let user_store = self.user_store.clone();
|
|
let channel_store = self.channel_store.clone();
|
|
let members = self.channel_store.update(cx, |channel_store, cx| {
|
|
channel_store.get_channel_member_details(channel_id, cx)
|
|
});
|
|
|
|
cx.spawn(|_, mut cx| async move {
|
|
let members = members.await?;
|
|
workspace.update(&mut cx, |workspace, cx| {
|
|
workspace.toggle_modal(cx, |cx| {
|
|
ChannelModal::new(
|
|
user_store.clone(),
|
|
channel_store.clone(),
|
|
channel_id,
|
|
mode,
|
|
members,
|
|
cx,
|
|
)
|
|
});
|
|
})
|
|
})
|
|
.detach();
|
|
}
|
|
|
|
fn remove_channel(&mut self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
|
|
let channel_store = self.channel_store.clone();
|
|
if let Some(channel) = channel_store.read(cx).channel_for_id(channel_id) {
|
|
let prompt_message = format!(
|
|
"Are you sure you want to remove the channel \"{}\"?",
|
|
channel.name
|
|
);
|
|
let answer = cx.prompt(PromptLevel::Warning, &prompt_message, &["Remove", "Cancel"]);
|
|
cx.spawn(|this, mut cx| async move {
|
|
if answer.await? == 0 {
|
|
channel_store
|
|
.update(&mut cx, |channels, _| channels.remove_channel(channel_id))?
|
|
.await
|
|
.notify_async_err(&mut cx);
|
|
this.update(&mut cx, |_, cx| cx.focus_self()).ok();
|
|
}
|
|
anyhow::Ok(())
|
|
})
|
|
.detach();
|
|
}
|
|
}
|
|
|
|
fn remove_contact(&mut self, user_id: u64, github_login: &str, cx: &mut ViewContext<Self>) {
|
|
let user_store = self.user_store.clone();
|
|
let prompt_message = format!(
|
|
"Are you sure you want to remove \"{}\" from your contacts?",
|
|
github_login
|
|
);
|
|
let answer = cx.prompt(PromptLevel::Warning, &prompt_message, &["Remove", "Cancel"]);
|
|
cx.spawn(|_, mut cx| async move {
|
|
if answer.await? == 0 {
|
|
user_store
|
|
.update(&mut cx, |store, cx| store.remove_contact(user_id, cx))?
|
|
.await
|
|
.notify_async_err(&mut cx);
|
|
}
|
|
anyhow::Ok(())
|
|
})
|
|
.detach_and_log_err(cx);
|
|
}
|
|
|
|
fn respond_to_contact_request(
|
|
&mut self,
|
|
user_id: u64,
|
|
accept: bool,
|
|
cx: &mut ViewContext<Self>,
|
|
) {
|
|
self.user_store
|
|
.update(cx, |store, cx| {
|
|
store.respond_to_contact_request(user_id, accept, cx)
|
|
})
|
|
.detach_and_log_err(cx);
|
|
}
|
|
|
|
fn respond_to_channel_invite(
|
|
&mut self,
|
|
channel_id: u64,
|
|
accept: bool,
|
|
cx: &mut ViewContext<Self>,
|
|
) {
|
|
self.channel_store
|
|
.update(cx, |store, cx| {
|
|
store.respond_to_channel_invite(channel_id, accept, cx)
|
|
})
|
|
.detach();
|
|
}
|
|
|
|
fn call(&mut self, recipient_user_id: u64, cx: &mut ViewContext<Self>) {
|
|
ActiveCall::global(cx)
|
|
.update(cx, |call, cx| {
|
|
call.invite(recipient_user_id, Some(self.project.clone()), cx)
|
|
})
|
|
.detach_and_log_err(cx);
|
|
}
|
|
|
|
fn join_channel(&self, channel_id: u64, cx: &mut ViewContext<Self>) {
|
|
let Some(workspace) = self.workspace.upgrade() else {
|
|
return;
|
|
};
|
|
let Some(handle) = cx.window_handle().downcast::<Workspace>() else {
|
|
return;
|
|
};
|
|
workspace::join_channel(
|
|
channel_id,
|
|
workspace.read(cx).app_state().clone(),
|
|
Some(handle),
|
|
cx,
|
|
)
|
|
.detach_and_log_err(cx)
|
|
}
|
|
|
|
fn join_channel_chat(&mut self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
|
|
let Some(workspace) = self.workspace.upgrade() else {
|
|
return;
|
|
};
|
|
cx.window_context().defer(move |cx| {
|
|
workspace.update(cx, |workspace, cx| {
|
|
if let Some(panel) = workspace.focus_panel::<ChatPanel>(cx) {
|
|
panel.update(cx, |panel, cx| {
|
|
panel
|
|
.select_channel(channel_id, None, cx)
|
|
.detach_and_log_err(cx);
|
|
});
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
fn copy_channel_link(&mut self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
|
|
let channel_store = self.channel_store.read(cx);
|
|
let Some(channel) = channel_store.channel_for_id(channel_id) else {
|
|
return;
|
|
};
|
|
let item = ClipboardItem::new(channel.link());
|
|
cx.write_to_clipboard(item)
|
|
}
|
|
|
|
fn render_signed_out(&mut self, cx: &mut ViewContext<Self>) -> Div {
|
|
let collab_blurb = "Work with your team in realtime with collaborative editing, voice, shared notes and more.";
|
|
|
|
v_stack()
|
|
.gap_6()
|
|
.p_4()
|
|
.child(Label::new(collab_blurb))
|
|
.child(
|
|
v_stack()
|
|
.gap_2()
|
|
.child(
|
|
Button::new("sign_in", "Sign in")
|
|
.icon_color(Color::Muted)
|
|
.icon(Icon::Github)
|
|
.icon_position(IconPosition::Start)
|
|
.style(ButtonStyle::Filled)
|
|
.full_width()
|
|
.on_click(cx.listener(|this, _, cx| {
|
|
let client = this.client.clone();
|
|
cx.spawn(|_, mut cx| async move {
|
|
client
|
|
.authenticate_and_connect(true, &cx)
|
|
.await
|
|
.notify_async_err(&mut cx);
|
|
})
|
|
.detach()
|
|
})),
|
|
)
|
|
.child(
|
|
div().flex().w_full().items_center().child(
|
|
Label::new("Sign in to enable collaboration.")
|
|
.color(Color::Muted)
|
|
.size(LabelSize::Small),
|
|
),
|
|
),
|
|
)
|
|
}
|
|
|
|
fn render_list_entry(&mut self, ix: usize, cx: &mut ViewContext<Self>) -> AnyElement {
|
|
let entry = &self.entries[ix];
|
|
|
|
let is_selected = self.selection == Some(ix);
|
|
match entry {
|
|
ListEntry::Header(section) => {
|
|
let is_collapsed = self.collapsed_sections.contains(section);
|
|
self.render_header(*section, is_selected, is_collapsed, cx)
|
|
.into_any_element()
|
|
}
|
|
ListEntry::Contact { contact, calling } => self
|
|
.render_contact(contact, *calling, is_selected, cx)
|
|
.into_any_element(),
|
|
ListEntry::ContactPlaceholder => self
|
|
.render_contact_placeholder(is_selected, cx)
|
|
.into_any_element(),
|
|
ListEntry::IncomingRequest(user) => self
|
|
.render_contact_request(user, true, is_selected, cx)
|
|
.into_any_element(),
|
|
ListEntry::OutgoingRequest(user) => self
|
|
.render_contact_request(user, false, is_selected, cx)
|
|
.into_any_element(),
|
|
ListEntry::Channel {
|
|
channel,
|
|
depth,
|
|
has_children,
|
|
} => self
|
|
.render_channel(channel, *depth, *has_children, is_selected, ix, cx)
|
|
.into_any_element(),
|
|
ListEntry::ChannelEditor { depth } => {
|
|
self.render_channel_editor(*depth, cx).into_any_element()
|
|
}
|
|
ListEntry::ChannelInvite(channel) => self
|
|
.render_channel_invite(channel, is_selected, cx)
|
|
.into_any_element(),
|
|
ListEntry::CallParticipant {
|
|
user,
|
|
peer_id,
|
|
is_pending,
|
|
} => self
|
|
.render_call_participant(user, *peer_id, *is_pending, is_selected, cx)
|
|
.into_any_element(),
|
|
ListEntry::ParticipantProject {
|
|
project_id,
|
|
worktree_root_names,
|
|
host_user_id,
|
|
is_last,
|
|
} => self
|
|
.render_participant_project(
|
|
*project_id,
|
|
&worktree_root_names,
|
|
*host_user_id,
|
|
*is_last,
|
|
is_selected,
|
|
cx,
|
|
)
|
|
.into_any_element(),
|
|
ListEntry::ParticipantScreen { peer_id, is_last } => self
|
|
.render_participant_screen(*peer_id, *is_last, is_selected, cx)
|
|
.into_any_element(),
|
|
ListEntry::ChannelNotes { channel_id } => self
|
|
.render_channel_notes(*channel_id, is_selected, cx)
|
|
.into_any_element(),
|
|
ListEntry::ChannelChat { channel_id } => self
|
|
.render_channel_chat(*channel_id, is_selected, cx)
|
|
.into_any_element(),
|
|
}
|
|
}
|
|
|
|
fn render_signed_in(&mut self, cx: &mut ViewContext<Self>) -> Div {
|
|
v_stack()
|
|
.size_full()
|
|
.child(list(self.list_state.clone()).full())
|
|
.child(
|
|
v_stack().p_2().child(
|
|
v_stack()
|
|
.border_primary(cx)
|
|
.border_t()
|
|
.child(self.filter_editor.clone()),
|
|
),
|
|
)
|
|
}
|
|
|
|
fn render_header(
|
|
&self,
|
|
section: Section,
|
|
is_selected: bool,
|
|
is_collapsed: bool,
|
|
cx: &ViewContext<Self>,
|
|
) -> impl IntoElement {
|
|
let mut channel_link = None;
|
|
let mut channel_tooltip_text = None;
|
|
let mut channel_icon = None;
|
|
// let mut is_dragged_over = false;
|
|
|
|
let text = match section {
|
|
Section::ActiveCall => {
|
|
let channel_name = maybe!({
|
|
let channel_id = ActiveCall::global(cx).read(cx).channel_id(cx)?;
|
|
|
|
let channel = self.channel_store.read(cx).channel_for_id(channel_id)?;
|
|
|
|
channel_link = Some(channel.link());
|
|
(channel_icon, channel_tooltip_text) = match channel.visibility {
|
|
proto::ChannelVisibility::Public => {
|
|
(Some("icons/public.svg"), Some("Copy public channel link."))
|
|
}
|
|
proto::ChannelVisibility::Members => {
|
|
(Some("icons/hash.svg"), Some("Copy private channel link."))
|
|
}
|
|
};
|
|
|
|
Some(channel.name.as_ref())
|
|
});
|
|
|
|
if let Some(name) = channel_name {
|
|
SharedString::from(format!("{}", name))
|
|
} else {
|
|
SharedString::from("Current Call")
|
|
}
|
|
}
|
|
Section::ContactRequests => SharedString::from("Requests"),
|
|
Section::Contacts => SharedString::from("Contacts"),
|
|
Section::Channels => SharedString::from("Channels"),
|
|
Section::ChannelInvites => SharedString::from("Invites"),
|
|
Section::Online => SharedString::from("Online"),
|
|
Section::Offline => SharedString::from("Offline"),
|
|
};
|
|
|
|
let button = match section {
|
|
Section::ActiveCall => channel_link.map(|channel_link| {
|
|
let channel_link_copy = channel_link.clone();
|
|
IconButton::new("channel-link", Icon::Copy)
|
|
.icon_size(IconSize::Small)
|
|
.size(ButtonSize::None)
|
|
.visible_on_hover("section-header")
|
|
.on_click(move |_, cx| {
|
|
let item = ClipboardItem::new(channel_link_copy.clone());
|
|
cx.write_to_clipboard(item)
|
|
})
|
|
.tooltip(|cx| Tooltip::text("Copy channel link", cx))
|
|
.into_any_element()
|
|
}),
|
|
Section::Contacts => Some(
|
|
IconButton::new("add-contact", Icon::Plus)
|
|
.on_click(cx.listener(|this, _, cx| this.toggle_contact_finder(cx)))
|
|
.tooltip(|cx| Tooltip::text("Search for new contact", cx))
|
|
.into_any_element(),
|
|
),
|
|
Section::Channels => Some(
|
|
IconButton::new("add-channel", Icon::Plus)
|
|
.on_click(cx.listener(|this, _, cx| this.new_root_channel(cx)))
|
|
.tooltip(|cx| Tooltip::text("Create a channel", cx))
|
|
.into_any_element(),
|
|
),
|
|
_ => None,
|
|
};
|
|
|
|
let can_collapse = match section {
|
|
Section::ActiveCall | Section::Channels | Section::Contacts => false,
|
|
Section::ChannelInvites
|
|
| Section::ContactRequests
|
|
| Section::Online
|
|
| Section::Offline => true,
|
|
};
|
|
|
|
h_stack()
|
|
.w_full()
|
|
.group("section-header")
|
|
.child(
|
|
ListHeader::new(text)
|
|
.when(can_collapse, |header| {
|
|
header.toggle(Some(!is_collapsed)).on_toggle(cx.listener(
|
|
move |this, _, cx| {
|
|
this.toggle_section_expanded(section, cx);
|
|
},
|
|
))
|
|
})
|
|
.inset(true)
|
|
.end_slot::<AnyElement>(button)
|
|
.selected(is_selected),
|
|
)
|
|
.when(section == Section::Channels, |el| {
|
|
el.drag_over::<Channel>(|style| style.bg(cx.theme().colors().ghost_element_hover))
|
|
.on_drop(cx.listener(move |this, dragged_channel: &Channel, cx| {
|
|
this.channel_store
|
|
.update(cx, |channel_store, cx| {
|
|
channel_store.move_channel(dragged_channel.id, None, cx)
|
|
})
|
|
.detach_and_log_err(cx)
|
|
}))
|
|
})
|
|
}
|
|
|
|
fn render_contact(
|
|
&self,
|
|
contact: &Contact,
|
|
calling: bool,
|
|
is_selected: bool,
|
|
cx: &mut ViewContext<Self>,
|
|
) -> impl IntoElement {
|
|
let online = contact.online;
|
|
let busy = contact.busy || calling;
|
|
let user_id = contact.user.id;
|
|
let github_login = SharedString::from(contact.user.github_login.clone());
|
|
let item =
|
|
ListItem::new(github_login.clone())
|
|
.indent_level(1)
|
|
.indent_step_size(px(20.))
|
|
.selected(is_selected)
|
|
.on_click(cx.listener(move |this, _, cx| this.call(user_id, cx)))
|
|
.child(
|
|
h_stack()
|
|
.w_full()
|
|
.justify_between()
|
|
.child(Label::new(github_login.clone()))
|
|
.when(calling, |el| {
|
|
el.child(Label::new("Calling").color(Color::Muted))
|
|
})
|
|
.when(!calling, |el| {
|
|
el.child(
|
|
IconButton::new("remove_contact", Icon::Close)
|
|
.icon_color(Color::Muted)
|
|
.visible_on_hover("")
|
|
.tooltip(|cx| Tooltip::text("Remove Contact", cx))
|
|
.on_click(cx.listener({
|
|
let github_login = github_login.clone();
|
|
move |this, _, cx| {
|
|
this.remove_contact(user_id, &github_login, cx);
|
|
}
|
|
})),
|
|
)
|
|
}),
|
|
)
|
|
.start_slot(
|
|
// todo!() handle contacts with no avatar
|
|
Avatar::new(contact.user.avatar_uri.clone())
|
|
.availability_indicator(if online { Some(!busy) } else { None }),
|
|
)
|
|
.when(online && !busy, |el| {
|
|
el.on_click(cx.listener(move |this, _, cx| this.call(user_id, cx)))
|
|
});
|
|
|
|
div()
|
|
.id(github_login.clone())
|
|
.group("")
|
|
.child(item)
|
|
.tooltip(move |cx| {
|
|
let text = if !online {
|
|
format!(" {} is offline", &github_login)
|
|
} else if busy {
|
|
format!(" {} is on a call", &github_login)
|
|
} else {
|
|
let room = ActiveCall::global(cx).read(cx).room();
|
|
if room.is_some() {
|
|
format!("Invite {} to join call", &github_login)
|
|
} else {
|
|
format!("Call {}", &github_login)
|
|
}
|
|
};
|
|
Tooltip::text(text, cx)
|
|
})
|
|
}
|
|
|
|
fn render_contact_request(
|
|
&self,
|
|
user: &Arc<User>,
|
|
is_incoming: bool,
|
|
is_selected: bool,
|
|
cx: &mut ViewContext<Self>,
|
|
) -> impl IntoElement {
|
|
let github_login = SharedString::from(user.github_login.clone());
|
|
let user_id = user.id;
|
|
let is_response_pending = self.user_store.read(cx).is_contact_request_pending(&user);
|
|
let color = if is_response_pending {
|
|
Color::Muted
|
|
} else {
|
|
Color::Default
|
|
};
|
|
|
|
let controls = if is_incoming {
|
|
vec![
|
|
IconButton::new("decline-contact", Icon::Close)
|
|
.on_click(cx.listener(move |this, _, cx| {
|
|
this.respond_to_contact_request(user_id, false, cx);
|
|
}))
|
|
.icon_color(color)
|
|
.tooltip(|cx| Tooltip::text("Decline invite", cx)),
|
|
IconButton::new("accept-contact", Icon::Check)
|
|
.on_click(cx.listener(move |this, _, cx| {
|
|
this.respond_to_contact_request(user_id, true, cx);
|
|
}))
|
|
.icon_color(color)
|
|
.tooltip(|cx| Tooltip::text("Accept invite", cx)),
|
|
]
|
|
} else {
|
|
let github_login = github_login.clone();
|
|
vec![IconButton::new("remove_contact", Icon::Close)
|
|
.on_click(cx.listener(move |this, _, cx| {
|
|
this.remove_contact(user_id, &github_login, cx);
|
|
}))
|
|
.icon_color(color)
|
|
.tooltip(|cx| Tooltip::text("Cancel invite", cx))]
|
|
};
|
|
|
|
ListItem::new(github_login.clone())
|
|
.indent_level(1)
|
|
.indent_step_size(px(20.))
|
|
.selected(is_selected)
|
|
.child(
|
|
h_stack()
|
|
.w_full()
|
|
.justify_between()
|
|
.child(Label::new(github_login.clone()))
|
|
.child(h_stack().children(controls)),
|
|
)
|
|
.start_slot(Avatar::new(user.avatar_uri.clone()))
|
|
}
|
|
|
|
fn render_channel_invite(
|
|
&self,
|
|
channel: &Arc<Channel>,
|
|
is_selected: bool,
|
|
cx: &mut ViewContext<Self>,
|
|
) -> ListItem {
|
|
let channel_id = channel.id;
|
|
let response_is_pending = self
|
|
.channel_store
|
|
.read(cx)
|
|
.has_pending_channel_invite_response(&channel);
|
|
let color = if response_is_pending {
|
|
Color::Muted
|
|
} else {
|
|
Color::Default
|
|
};
|
|
|
|
let controls = [
|
|
IconButton::new("reject-invite", Icon::Close)
|
|
.on_click(cx.listener(move |this, _, cx| {
|
|
this.respond_to_channel_invite(channel_id, false, cx);
|
|
}))
|
|
.icon_color(color)
|
|
.tooltip(|cx| Tooltip::text("Decline invite", cx)),
|
|
IconButton::new("accept-invite", Icon::Check)
|
|
.on_click(cx.listener(move |this, _, cx| {
|
|
this.respond_to_channel_invite(channel_id, true, cx);
|
|
}))
|
|
.icon_color(color)
|
|
.tooltip(|cx| Tooltip::text("Accept invite", cx)),
|
|
];
|
|
|
|
ListItem::new(("channel-invite", channel.id as usize))
|
|
.selected(is_selected)
|
|
.child(
|
|
h_stack()
|
|
.w_full()
|
|
.justify_between()
|
|
.child(Label::new(channel.name.clone()))
|
|
.child(h_stack().children(controls)),
|
|
)
|
|
.start_slot(
|
|
IconElement::new(Icon::Hash)
|
|
.size(IconSize::Small)
|
|
.color(Color::Muted),
|
|
)
|
|
}
|
|
|
|
fn render_contact_placeholder(
|
|
&self,
|
|
is_selected: bool,
|
|
cx: &mut ViewContext<Self>,
|
|
) -> ListItem {
|
|
ListItem::new("contact-placeholder")
|
|
.child(IconElement::new(Icon::Plus))
|
|
.child(Label::new("Add a Contact"))
|
|
.selected(is_selected)
|
|
.on_click(cx.listener(|this, _, cx| this.toggle_contact_finder(cx)))
|
|
}
|
|
|
|
fn render_channel(
|
|
&self,
|
|
channel: &Channel,
|
|
depth: usize,
|
|
has_children: bool,
|
|
is_selected: bool,
|
|
ix: usize,
|
|
cx: &mut ViewContext<Self>,
|
|
) -> impl IntoElement {
|
|
let channel_id = channel.id;
|
|
|
|
let is_active = maybe!({
|
|
let call_channel = ActiveCall::global(cx)
|
|
.read(cx)
|
|
.room()?
|
|
.read(cx)
|
|
.channel_id()?;
|
|
Some(call_channel == channel_id)
|
|
})
|
|
.unwrap_or(false);
|
|
let is_public = self
|
|
.channel_store
|
|
.read(cx)
|
|
.channel_for_id(channel_id)
|
|
.map(|channel| channel.visibility)
|
|
== Some(proto::ChannelVisibility::Public);
|
|
let disclosed =
|
|
has_children.then(|| !self.collapsed_channels.binary_search(&channel.id).is_ok());
|
|
|
|
let has_messages_notification = channel.unseen_message_id.is_some();
|
|
let has_notes_notification = channel.unseen_note_version.is_some();
|
|
|
|
const FACEPILE_LIMIT: usize = 3;
|
|
let participants = self.channel_store.read(cx).channel_participants(channel_id);
|
|
|
|
let face_pile = if !participants.is_empty() {
|
|
let extra_count = participants.len().saturating_sub(FACEPILE_LIMIT);
|
|
let result = FacePile {
|
|
faces: participants
|
|
.iter()
|
|
.map(|user| Avatar::new(user.avatar_uri.clone()).into_any_element())
|
|
.take(FACEPILE_LIMIT)
|
|
.chain(if extra_count > 0 {
|
|
// todo!() @nate - this label looks wrong.
|
|
Some(Label::new(format!("+{}", extra_count)).into_any_element())
|
|
} else {
|
|
None
|
|
})
|
|
.collect::<SmallVec<_>>(),
|
|
};
|
|
|
|
Some(result)
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let width = self.width.unwrap_or(px(240.));
|
|
|
|
div()
|
|
.id(channel_id as usize)
|
|
.group("")
|
|
.flex()
|
|
.w_full()
|
|
.on_drag(channel.clone(), move |channel, cx| {
|
|
cx.new_view(|_| DraggedChannelView {
|
|
channel: channel.clone(),
|
|
width,
|
|
})
|
|
})
|
|
.drag_over::<Channel>(|style| style.bg(cx.theme().colors().ghost_element_hover))
|
|
.on_drop(cx.listener(move |this, dragged_channel: &Channel, cx| {
|
|
this.channel_store
|
|
.update(cx, |channel_store, cx| {
|
|
channel_store.move_channel(dragged_channel.id, Some(channel_id), cx)
|
|
})
|
|
.detach_and_log_err(cx)
|
|
}))
|
|
.child(
|
|
ListItem::new(channel_id as usize)
|
|
// Add one level of depth for the disclosure arrow.
|
|
.indent_level(depth + 1)
|
|
.indent_step_size(px(20.))
|
|
.selected(is_selected || is_active)
|
|
.toggle(disclosed)
|
|
.on_toggle(
|
|
cx.listener(move |this, _, cx| {
|
|
this.toggle_channel_collapsed(channel_id, cx)
|
|
}),
|
|
)
|
|
.on_click(cx.listener(move |this, _, cx| {
|
|
if is_active {
|
|
this.open_channel_notes(channel_id, cx)
|
|
} else {
|
|
this.join_channel(channel_id, cx)
|
|
}
|
|
}))
|
|
.on_secondary_mouse_down(cx.listener(
|
|
move |this, event: &MouseDownEvent, cx| {
|
|
this.deploy_channel_context_menu(event.position, channel_id, ix, cx)
|
|
},
|
|
))
|
|
.start_slot(
|
|
IconElement::new(if is_public { Icon::Public } else { Icon::Hash })
|
|
.size(IconSize::Small)
|
|
.color(Color::Muted),
|
|
)
|
|
.child(
|
|
h_stack()
|
|
.id(channel_id as usize)
|
|
.child(Label::new(channel.name.clone()))
|
|
.children(face_pile.map(|face_pile| face_pile.render(cx))),
|
|
)
|
|
.end_slot(
|
|
h_stack()
|
|
.absolute()
|
|
.right_0()
|
|
// HACK: Without this the channel name clips on top of the icons, but I'm not sure why.
|
|
.z_index(10)
|
|
.bg(cx.theme().colors().panel_background)
|
|
.child(
|
|
h_stack()
|
|
// The element hover background has a slight transparency to it, so we
|
|
// need to apply it to the inner element so that it blends with the solid
|
|
// background color of the absolutely-positioned element.
|
|
.group_hover("", |style| {
|
|
style.bg(cx.theme().colors().ghost_element_hover)
|
|
})
|
|
.child(
|
|
IconButton::new("channel_chat", Icon::MessageBubbles)
|
|
.icon_size(IconSize::Small)
|
|
.icon_color(if has_messages_notification {
|
|
Color::Default
|
|
} else {
|
|
Color::Muted
|
|
})
|
|
.when(!has_messages_notification, |this| {
|
|
this.visible_on_hover("")
|
|
})
|
|
.on_click(cx.listener(move |this, _, cx| {
|
|
this.join_channel_chat(channel_id, cx)
|
|
}))
|
|
.tooltip(|cx| Tooltip::text("Open channel chat", cx)),
|
|
)
|
|
.child(
|
|
IconButton::new("channel_notes", Icon::File)
|
|
.icon_size(IconSize::Small)
|
|
.icon_color(if has_notes_notification {
|
|
Color::Default
|
|
} else {
|
|
Color::Muted
|
|
})
|
|
.when(!has_notes_notification, |this| {
|
|
this.visible_on_hover("")
|
|
})
|
|
.on_click(cx.listener(move |this, _, cx| {
|
|
this.open_channel_notes(channel_id, cx)
|
|
}))
|
|
.tooltip(|cx| Tooltip::text("Open channel notes", cx)),
|
|
),
|
|
),
|
|
),
|
|
)
|
|
.tooltip(|cx| Tooltip::text("Join channel", cx))
|
|
}
|
|
|
|
fn render_channel_editor(&self, depth: usize, _cx: &mut ViewContext<Self>) -> impl IntoElement {
|
|
let item = ListItem::new("channel-editor")
|
|
.inset(false)
|
|
// Add one level of depth for the disclosure arrow.
|
|
.indent_level(depth + 1)
|
|
.indent_step_size(px(20.))
|
|
.start_slot(
|
|
IconElement::new(Icon::Hash)
|
|
.size(IconSize::Small)
|
|
.color(Color::Muted),
|
|
);
|
|
|
|
if let Some(pending_name) = self
|
|
.channel_editing_state
|
|
.as_ref()
|
|
.and_then(|state| state.pending_name())
|
|
{
|
|
item.child(Label::new(pending_name))
|
|
} else {
|
|
item.child(
|
|
div()
|
|
.w_full()
|
|
.py_1() // todo!() @nate this is a px off at the default font size.
|
|
.child(self.channel_name_editor.clone()),
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn render_tree_branch(is_last: bool, cx: &mut WindowContext) -> impl IntoElement {
|
|
let rem_size = cx.rem_size();
|
|
let line_height = cx.text_style().line_height_in_pixels(rem_size);
|
|
let width = rem_size * 1.5;
|
|
let thickness = px(2.);
|
|
let color = cx.theme().colors().text;
|
|
|
|
canvas(move |bounds, cx| {
|
|
let start_x = (bounds.left() + bounds.right() - thickness) / 2.;
|
|
let start_y = (bounds.top() + bounds.bottom() - thickness) / 2.;
|
|
let right = bounds.right();
|
|
let top = bounds.top();
|
|
|
|
cx.paint_quad(fill(
|
|
Bounds::from_corners(
|
|
point(start_x, top),
|
|
point(
|
|
start_x + thickness,
|
|
if is_last { start_y } else { bounds.bottom() },
|
|
),
|
|
),
|
|
color,
|
|
));
|
|
cx.paint_quad(fill(
|
|
Bounds::from_corners(point(start_x, start_y), point(right, start_y + thickness)),
|
|
color,
|
|
));
|
|
})
|
|
.w(width)
|
|
.h(line_height)
|
|
}
|
|
|
|
impl Render for CollabPanel {
|
|
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl Element {
|
|
v_stack()
|
|
.key_context("CollabPanel")
|
|
.on_action(cx.listener(CollabPanel::cancel))
|
|
.on_action(cx.listener(CollabPanel::select_next))
|
|
.on_action(cx.listener(CollabPanel::select_prev))
|
|
.on_action(cx.listener(CollabPanel::confirm))
|
|
.on_action(cx.listener(CollabPanel::insert_space))
|
|
.on_action(cx.listener(CollabPanel::remove_selected_channel))
|
|
.on_action(cx.listener(CollabPanel::show_inline_context_menu))
|
|
.on_action(cx.listener(CollabPanel::rename_selected_channel))
|
|
.on_action(cx.listener(CollabPanel::collapse_selected_channel))
|
|
.on_action(cx.listener(CollabPanel::expand_selected_channel))
|
|
.on_action(cx.listener(CollabPanel::start_move_selected_channel))
|
|
.track_focus(&self.focus_handle)
|
|
.size_full()
|
|
.child(if self.user_store.read(cx).current_user().is_none() {
|
|
self.render_signed_out(cx)
|
|
} else {
|
|
self.render_signed_in(cx)
|
|
})
|
|
.children(self.context_menu.as_ref().map(|(menu, position, _)| {
|
|
overlay()
|
|
.position(*position)
|
|
.anchor(gpui::AnchorCorner::TopLeft)
|
|
.child(menu.clone())
|
|
}))
|
|
}
|
|
}
|
|
|
|
impl EventEmitter<PanelEvent> for CollabPanel {}
|
|
|
|
impl Panel for CollabPanel {
|
|
fn position(&self, cx: &gpui::WindowContext) -> DockPosition {
|
|
CollaborationPanelSettings::get_global(cx).dock
|
|
}
|
|
|
|
fn position_is_valid(&self, position: DockPosition) -> bool {
|
|
matches!(position, DockPosition::Left | DockPosition::Right)
|
|
}
|
|
|
|
fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
|
|
settings::update_settings_file::<CollaborationPanelSettings>(
|
|
self.fs.clone(),
|
|
cx,
|
|
move |settings| settings.dock = Some(position),
|
|
);
|
|
}
|
|
|
|
fn size(&self, cx: &gpui::WindowContext) -> Pixels {
|
|
self.width
|
|
.unwrap_or_else(|| CollaborationPanelSettings::get_global(cx).default_width)
|
|
}
|
|
|
|
fn set_size(&mut self, size: Option<Pixels>, cx: &mut ViewContext<Self>) {
|
|
self.width = size;
|
|
self.serialize(cx);
|
|
cx.notify();
|
|
}
|
|
|
|
fn icon(&self, cx: &gpui::WindowContext) -> Option<ui::Icon> {
|
|
CollaborationPanelSettings::get_global(cx)
|
|
.button
|
|
.then(|| ui::Icon::Collab)
|
|
}
|
|
|
|
fn toggle_action(&self) -> Box<dyn gpui::Action> {
|
|
Box::new(ToggleFocus)
|
|
}
|
|
|
|
fn persistent_name() -> &'static str {
|
|
"CollabPanel"
|
|
}
|
|
}
|
|
|
|
impl FocusableView for CollabPanel {
|
|
fn focus_handle(&self, cx: &AppContext) -> gpui::FocusHandle {
|
|
self.filter_editor.focus_handle(cx).clone()
|
|
}
|
|
}
|
|
|
|
impl PartialEq for ListEntry {
|
|
fn eq(&self, other: &Self) -> bool {
|
|
match self {
|
|
ListEntry::Header(section_1) => {
|
|
if let ListEntry::Header(section_2) = other {
|
|
return section_1 == section_2;
|
|
}
|
|
}
|
|
ListEntry::CallParticipant { user: user_1, .. } => {
|
|
if let ListEntry::CallParticipant { user: user_2, .. } = other {
|
|
return user_1.id == user_2.id;
|
|
}
|
|
}
|
|
ListEntry::ParticipantProject {
|
|
project_id: project_id_1,
|
|
..
|
|
} => {
|
|
if let ListEntry::ParticipantProject {
|
|
project_id: project_id_2,
|
|
..
|
|
} = other
|
|
{
|
|
return project_id_1 == project_id_2;
|
|
}
|
|
}
|
|
ListEntry::ParticipantScreen {
|
|
peer_id: peer_id_1, ..
|
|
} => {
|
|
if let ListEntry::ParticipantScreen {
|
|
peer_id: peer_id_2, ..
|
|
} = other
|
|
{
|
|
return peer_id_1 == peer_id_2;
|
|
}
|
|
}
|
|
ListEntry::Channel {
|
|
channel: channel_1, ..
|
|
} => {
|
|
if let ListEntry::Channel {
|
|
channel: channel_2, ..
|
|
} = other
|
|
{
|
|
return channel_1.id == channel_2.id;
|
|
}
|
|
}
|
|
ListEntry::ChannelNotes { channel_id } => {
|
|
if let ListEntry::ChannelNotes {
|
|
channel_id: other_id,
|
|
} = other
|
|
{
|
|
return channel_id == other_id;
|
|
}
|
|
}
|
|
ListEntry::ChannelChat { channel_id } => {
|
|
if let ListEntry::ChannelChat {
|
|
channel_id: other_id,
|
|
} = other
|
|
{
|
|
return channel_id == other_id;
|
|
}
|
|
}
|
|
ListEntry::ChannelInvite(channel_1) => {
|
|
if let ListEntry::ChannelInvite(channel_2) = other {
|
|
return channel_1.id == channel_2.id;
|
|
}
|
|
}
|
|
ListEntry::IncomingRequest(user_1) => {
|
|
if let ListEntry::IncomingRequest(user_2) = other {
|
|
return user_1.id == user_2.id;
|
|
}
|
|
}
|
|
ListEntry::OutgoingRequest(user_1) => {
|
|
if let ListEntry::OutgoingRequest(user_2) = other {
|
|
return user_1.id == user_2.id;
|
|
}
|
|
}
|
|
ListEntry::Contact {
|
|
contact: contact_1, ..
|
|
} => {
|
|
if let ListEntry::Contact {
|
|
contact: contact_2, ..
|
|
} = other
|
|
{
|
|
return contact_1.user.id == contact_2.user.id;
|
|
}
|
|
}
|
|
ListEntry::ChannelEditor { depth } => {
|
|
if let ListEntry::ChannelEditor { depth: other_depth } = other {
|
|
return depth == other_depth;
|
|
}
|
|
}
|
|
ListEntry::ContactPlaceholder => {
|
|
if let ListEntry::ContactPlaceholder = other {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
false
|
|
}
|
|
}
|
|
|
|
struct DraggedChannelView {
|
|
channel: Channel,
|
|
width: Pixels,
|
|
}
|
|
|
|
impl Render for DraggedChannelView {
|
|
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl Element {
|
|
let ui_font = ThemeSettings::get_global(cx).ui_font.family.clone();
|
|
h_stack()
|
|
.font(ui_font)
|
|
.bg(cx.theme().colors().background)
|
|
.w(self.width)
|
|
.p_1()
|
|
.gap_1()
|
|
.child(
|
|
IconElement::new(
|
|
if self.channel.visibility == proto::ChannelVisibility::Public {
|
|
Icon::Public
|
|
} else {
|
|
Icon::Hash
|
|
},
|
|
)
|
|
.size(IconSize::Small)
|
|
.color(Color::Muted),
|
|
)
|
|
.child(Label::new(self.channel.name.clone()))
|
|
}
|
|
}
|