From 84bcbf11284dce95b14f23795d029f069153b940 Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Wed, 15 Nov 2023 14:24:01 -0700 Subject: [PATCH 1/2] Add collab_ui2 --- Cargo.lock | 41 + crates/collab_ui2/Cargo.toml | 80 + crates/collab_ui2/src/channel_view.rs | 454 +++ crates/collab_ui2/src/chat_panel.rs | 983 +++++ .../src/chat_panel/message_editor.rs | 313 ++ crates/collab_ui2/src/collab_panel.rs | 3548 +++++++++++++++++ .../src/collab_panel/channel_modal.rs | 717 ++++ .../src/collab_panel/contact_finder.rs | 261 ++ crates/collab_ui2/src/collab_titlebar_item.rs | 1278 ++++++ crates/collab_ui2/src/collab_ui.rs | 165 + crates/collab_ui2/src/face_pile.rs | 113 + crates/collab_ui2/src/notification_panel.rs | 884 ++++ crates/collab_ui2/src/notifications.rs | 11 + .../incoming_call_notification.rs | 213 + .../project_shared_notification.rs | 217 + crates/collab_ui2/src/panel_settings.rs | 69 + crates/zed2/Cargo.toml | 2 +- 17 files changed, 9348 insertions(+), 1 deletion(-) create mode 100644 crates/collab_ui2/Cargo.toml create mode 100644 crates/collab_ui2/src/channel_view.rs create mode 100644 crates/collab_ui2/src/chat_panel.rs create mode 100644 crates/collab_ui2/src/chat_panel/message_editor.rs create mode 100644 crates/collab_ui2/src/collab_panel.rs create mode 100644 crates/collab_ui2/src/collab_panel/channel_modal.rs create mode 100644 crates/collab_ui2/src/collab_panel/contact_finder.rs create mode 100644 crates/collab_ui2/src/collab_titlebar_item.rs create mode 100644 crates/collab_ui2/src/collab_ui.rs create mode 100644 crates/collab_ui2/src/face_pile.rs create mode 100644 crates/collab_ui2/src/notification_panel.rs create mode 100644 crates/collab_ui2/src/notifications.rs create mode 100644 crates/collab_ui2/src/notifications/incoming_call_notification.rs create mode 100644 crates/collab_ui2/src/notifications/project_shared_notification.rs create mode 100644 crates/collab_ui2/src/panel_settings.rs diff --git a/Cargo.lock b/Cargo.lock index 69f402f2e7c2dc87b74b38056b0e4a3e7dfd2b7e..de80a9a7b39a8c61a0940152156b1639be122562 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1829,6 +1829,46 @@ dependencies = [ "zed-actions", ] +[[package]] +name = "collab_ui2" +version = "0.1.0" +dependencies = [ + "anyhow", + "call2", + "channel2", + "client2", + "clock", + "collections", + "db2", + "editor2", + "feature_flags2", + "futures 0.3.28", + "fuzzy", + "gpui2", + "language2", + "lazy_static", + "log", + "menu2", + "notifications2", + "picker2", + "postage", + "pretty_assertions", + "project2", + "rich_text2", + "rpc2", + "schemars", + "serde", + "serde_derive", + "settings2", + "smallvec", + "theme2", + "time", + "tree-sitter-markdown", + "util", + "workspace2", + "zed_actions2", +] + [[package]] name = "collections" version = "0.1.0" @@ -11441,6 +11481,7 @@ dependencies = [ "chrono", "cli", "client2", + "collab_ui2", "collections", "command_palette2", "copilot2", diff --git a/crates/collab_ui2/Cargo.toml b/crates/collab_ui2/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..8c48e0984697c19c265517c28051b36f4e3f93ec --- /dev/null +++ b/crates/collab_ui2/Cargo.toml @@ -0,0 +1,80 @@ +[package] +name = "collab_ui2" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +path = "src/collab_ui.rs" +doctest = false + +[features] +test-support = [ + "call/test-support", + "client/test-support", + "collections/test-support", + "editor/test-support", + "gpui/test-support", + "project/test-support", + "settings/test-support", + "util/test-support", + "workspace/test-support", +] + +[dependencies] +# auto_update = { path = "../auto_update" } +db = { package = "db2", path = "../db2" } +call = { package = "call2", path = "../call2" } +client = { package = "client2", path = "../client2" } +channel = { package = "channel2", path = "../channel2" } +clock = { path = "../clock" } +collections = { path = "../collections" } +# context_menu = { path = "../context_menu" } +# drag_and_drop = { path = "../drag_and_drop" } +editor = { package="editor2", path = "../editor2" } +#feedback = { path = "../feedback" } +fuzzy = { path = "../fuzzy" } +gpui = { package = "gpui2", path = "../gpui2" } +language = { package = "language2", path = "../language2" } +menu = { package = "menu2", path = "../menu2" } +notifications = { package = "notifications2", path = "../notifications2" } +rich_text = { package = "rich_text2", path = "../rich_text2" } +picker = { package = "picker2", path = "../picker2" } +project = { package = "project2", path = "../project2" } +# recent_projects = { path = "../recent_projects" } +rpc = { package ="rpc2", path = "../rpc2" } +settings = { package = "settings2", path = "../settings2" } +feature_flags = { package = "feature_flags2", path = "../feature_flags2"} +theme = { package = "theme2", path = "../theme2" } +# theme_selector = { path = "../theme_selector" } +# vcs_menu = { path = "../vcs_menu" } +util = { path = "../util" } +workspace = { package = "workspace2", path = "../workspace2" } +zed-actions = { package="zed_actions2", path = "../zed_actions2"} + +anyhow.workspace = true +futures.workspace = true +lazy_static.workspace = true +log.workspace = true +schemars.workspace = true +postage.workspace = true +serde.workspace = true +serde_derive.workspace = true +time.workspace = true +smallvec.workspace = true + +[dev-dependencies] +call = { package = "call2", path = "../call2", features = ["test-support"] } +client = { package = "client2", path = "../client2", features = ["test-support"] } +collections = { path = "../collections", features = ["test-support"] } +editor = { package = "editor2", path = "../editor2", features = ["test-support"] } +gpui = { package = "gpui2", path = "../gpui2", features = ["test-support"] } +notifications = { package = "notifications2", path = "../notifications2", features = ["test-support"] } +project = { package = "project2", path = "../project2", features = ["test-support"] } +rpc = { package = "rpc2", path = "../rpc2", features = ["test-support"] } +settings = { package = "settings2", path = "../settings2", features = ["test-support"] } +util = { path = "../util", features = ["test-support"] } +workspace = { package = "workspace2", path = "../workspace2", features = ["test-support"] } + +pretty_assertions.workspace = true +tree-sitter-markdown.workspace = true diff --git a/crates/collab_ui2/src/channel_view.rs b/crates/collab_ui2/src/channel_view.rs new file mode 100644 index 0000000000000000000000000000000000000000..fe46f3bb3e5dfc40720ca45c2873c17c9db7cd7a --- /dev/null +++ b/crates/collab_ui2/src/channel_view.rs @@ -0,0 +1,454 @@ +use anyhow::{anyhow, Result}; +use call::report_call_event_for_channel; +use channel::{Channel, ChannelBuffer, ChannelBufferEvent, ChannelId, ChannelStore}; +use client::{ + proto::{self, PeerId}, + Collaborator, ParticipantIndex, +}; +use collections::HashMap; +use editor::{CollaborationHub, Editor}; +use gpui::{ + actions, + elements::{ChildView, Label}, + geometry::vector::Vector2F, + AnyElement, AnyViewHandle, AppContext, Element, Entity, ModelHandle, Subscription, Task, View, + ViewContext, ViewHandle, +}; +use project::Project; +use smallvec::SmallVec; +use std::{ + any::{Any, TypeId}, + sync::Arc, +}; +use util::ResultExt; +use workspace::{ + item::{FollowableItem, Item, ItemEvent, ItemHandle}, + register_followable_item, + searchable::SearchableItemHandle, + ItemNavHistory, Pane, SaveIntent, ViewId, Workspace, WorkspaceId, +}; + +actions!(channel_view, [Deploy]); + +pub fn init(cx: &mut AppContext) { + register_followable_item::(cx) +} + +pub struct ChannelView { + pub editor: ViewHandle, + project: ModelHandle, + channel_store: ModelHandle, + channel_buffer: ModelHandle, + remote_id: Option, + _editor_event_subscription: Subscription, +} + +impl ChannelView { + pub fn open( + channel_id: ChannelId, + workspace: ViewHandle, + cx: &mut AppContext, + ) -> Task>> { + let pane = workspace.read(cx).active_pane().clone(); + let channel_view = Self::open_in_pane(channel_id, pane.clone(), workspace.clone(), cx); + cx.spawn(|mut cx| async move { + let channel_view = channel_view.await?; + pane.update(&mut cx, |pane, cx| { + report_call_event_for_channel( + "open channel notes", + channel_id, + &workspace.read(cx).app_state().client, + cx, + ); + pane.add_item(Box::new(channel_view.clone()), true, true, None, cx); + }); + anyhow::Ok(channel_view) + }) + } + + pub fn open_in_pane( + channel_id: ChannelId, + pane: ViewHandle, + workspace: ViewHandle, + cx: &mut AppContext, + ) -> Task>> { + let workspace = workspace.read(cx); + let project = workspace.project().to_owned(); + let channel_store = ChannelStore::global(cx); + let language_registry = workspace.app_state().languages.clone(); + let markdown = language_registry.language_for_name("Markdown"); + let channel_buffer = + channel_store.update(cx, |store, cx| store.open_channel_buffer(channel_id, cx)); + + cx.spawn(|mut cx| async move { + let channel_buffer = channel_buffer.await?; + let markdown = markdown.await.log_err(); + + channel_buffer.update(&mut cx, |buffer, cx| { + buffer.buffer().update(cx, |buffer, cx| { + buffer.set_language_registry(language_registry); + if let Some(markdown) = markdown { + buffer.set_language(Some(markdown), cx); + } + }) + }); + + pane.update(&mut cx, |pane, cx| { + let buffer_id = channel_buffer.read(cx).remote_id(cx); + + let existing_view = pane + .items_of_type::() + .find(|view| view.read(cx).channel_buffer.read(cx).remote_id(cx) == buffer_id); + + // If this channel buffer is already open in this pane, just return it. + if let Some(existing_view) = existing_view.clone() { + if existing_view.read(cx).channel_buffer == channel_buffer { + return existing_view; + } + } + + let view = cx.add_view(|cx| { + let mut this = Self::new(project, channel_store, channel_buffer, cx); + this.acknowledge_buffer_version(cx); + this + }); + + // If the pane contained a disconnected view for this channel buffer, + // replace that. + if let Some(existing_item) = existing_view { + if let Some(ix) = pane.index_for_item(&existing_item) { + pane.close_item_by_id(existing_item.id(), SaveIntent::Skip, cx) + .detach(); + pane.add_item(Box::new(view.clone()), true, true, Some(ix), cx); + } + } + + view + }) + .ok_or_else(|| anyhow!("pane was dropped")) + }) + } + + pub fn new( + project: ModelHandle, + channel_store: ModelHandle, + channel_buffer: ModelHandle, + cx: &mut ViewContext, + ) -> Self { + let buffer = channel_buffer.read(cx).buffer(); + let editor = cx.add_view(|cx| { + let mut editor = Editor::for_buffer(buffer, None, cx); + editor.set_collaboration_hub(Box::new(ChannelBufferCollaborationHub( + channel_buffer.clone(), + ))); + editor.set_read_only( + !channel_buffer + .read(cx) + .channel(cx) + .is_some_and(|c| c.can_edit_notes()), + ); + editor + }); + let _editor_event_subscription = cx.subscribe(&editor, |_, _, e, cx| cx.emit(e.clone())); + + cx.subscribe(&channel_buffer, Self::handle_channel_buffer_event) + .detach(); + + Self { + editor, + project, + channel_store, + channel_buffer, + remote_id: None, + _editor_event_subscription, + } + } + + pub fn channel(&self, cx: &AppContext) -> Option> { + self.channel_buffer.read(cx).channel(cx) + } + + fn handle_channel_buffer_event( + &mut self, + _: ModelHandle, + event: &ChannelBufferEvent, + cx: &mut ViewContext, + ) { + match event { + ChannelBufferEvent::Disconnected => self.editor.update(cx, |editor, cx| { + editor.set_read_only(true); + cx.notify(); + }), + ChannelBufferEvent::ChannelChanged => { + self.editor.update(cx, |editor, cx| { + editor.set_read_only(!self.channel(cx).is_some_and(|c| c.can_edit_notes())); + cx.emit(editor::Event::TitleChanged); + cx.notify() + }); + } + ChannelBufferEvent::BufferEdited => { + if cx.is_self_focused() || self.editor.is_focused(cx) { + self.acknowledge_buffer_version(cx); + } else { + self.channel_store.update(cx, |store, cx| { + let channel_buffer = self.channel_buffer.read(cx); + store.notes_changed( + channel_buffer.channel_id, + channel_buffer.epoch(), + &channel_buffer.buffer().read(cx).version(), + cx, + ) + }); + } + } + ChannelBufferEvent::CollaboratorsChanged => {} + } + } + + fn acknowledge_buffer_version(&mut self, cx: &mut ViewContext<'_, '_, ChannelView>) { + self.channel_store.update(cx, |store, cx| { + let channel_buffer = self.channel_buffer.read(cx); + store.acknowledge_notes_version( + channel_buffer.channel_id, + channel_buffer.epoch(), + &channel_buffer.buffer().read(cx).version(), + cx, + ) + }); + self.channel_buffer.update(cx, |buffer, cx| { + buffer.acknowledge_buffer_version(cx); + }); + } +} + +impl Entity for ChannelView { + type Event = editor::Event; +} + +impl View for ChannelView { + fn ui_name() -> &'static str { + "ChannelView" + } + + fn render(&mut self, cx: &mut ViewContext) -> AnyElement { + ChildView::new(self.editor.as_any(), cx).into_any() + } + + fn focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext) { + if cx.is_self_focused() { + self.acknowledge_buffer_version(cx); + cx.focus(self.editor.as_any()) + } + } +} + +impl Item for ChannelView { + fn act_as_type<'a>( + &'a self, + type_id: TypeId, + self_handle: &'a ViewHandle, + _: &'a AppContext, + ) -> Option<&'a AnyViewHandle> { + if type_id == TypeId::of::() { + Some(self_handle) + } else if type_id == TypeId::of::() { + Some(&self.editor) + } else { + None + } + } + + fn tab_content( + &self, + _: Option, + style: &theme::Tab, + cx: &gpui::AppContext, + ) -> AnyElement { + let label = if let Some(channel) = self.channel(cx) { + match ( + channel.can_edit_notes(), + self.channel_buffer.read(cx).is_connected(), + ) { + (true, true) => format!("#{}", channel.name), + (false, true) => format!("#{} (read-only)", channel.name), + (_, false) => format!("#{} (disconnected)", channel.name), + } + } else { + format!("channel notes (disconnected)") + }; + Label::new(label, style.label.to_owned()).into_any() + } + + fn clone_on_split(&self, _: WorkspaceId, cx: &mut ViewContext) -> Option { + Some(Self::new( + self.project.clone(), + self.channel_store.clone(), + self.channel_buffer.clone(), + cx, + )) + } + + fn is_singleton(&self, _cx: &AppContext) -> bool { + false + } + + fn navigate(&mut self, data: Box, cx: &mut ViewContext) -> bool { + self.editor + .update(cx, |editor, cx| editor.navigate(data, cx)) + } + + fn deactivated(&mut self, cx: &mut ViewContext) { + self.editor + .update(cx, |editor, cx| Item::deactivated(editor, cx)) + } + + fn set_nav_history(&mut self, history: ItemNavHistory, cx: &mut ViewContext) { + self.editor + .update(cx, |editor, cx| Item::set_nav_history(editor, history, cx)) + } + + fn as_searchable(&self, _: &ViewHandle) -> Option> { + Some(Box::new(self.editor.clone())) + } + + fn show_toolbar(&self) -> bool { + true + } + + fn pixel_position_of_cursor(&self, cx: &AppContext) -> Option { + self.editor.read(cx).pixel_position_of_cursor(cx) + } + + fn to_item_events(event: &Self::Event) -> SmallVec<[ItemEvent; 2]> { + editor::Editor::to_item_events(event) + } +} + +impl FollowableItem for ChannelView { + fn remote_id(&self) -> Option { + self.remote_id + } + + fn to_state_proto(&self, cx: &AppContext) -> Option { + let channel_buffer = self.channel_buffer.read(cx); + if !channel_buffer.is_connected() { + return None; + } + + Some(proto::view::Variant::ChannelView( + proto::view::ChannelView { + channel_id: channel_buffer.channel_id, + editor: if let Some(proto::view::Variant::Editor(proto)) = + self.editor.read(cx).to_state_proto(cx) + { + Some(proto) + } else { + None + }, + }, + )) + } + + fn from_state_proto( + pane: ViewHandle, + workspace: ViewHandle, + remote_id: workspace::ViewId, + state: &mut Option, + cx: &mut AppContext, + ) -> Option>>> { + let Some(proto::view::Variant::ChannelView(_)) = state else { + return None; + }; + let Some(proto::view::Variant::ChannelView(state)) = state.take() else { + unreachable!() + }; + + let open = ChannelView::open_in_pane(state.channel_id, pane, workspace, cx); + + Some(cx.spawn(|mut cx| async move { + let this = open.await?; + + let task = this + .update(&mut cx, |this, cx| { + this.remote_id = Some(remote_id); + + if let Some(state) = state.editor { + Some(this.editor.update(cx, |editor, cx| { + editor.apply_update_proto( + &this.project, + proto::update_view::Variant::Editor(proto::update_view::Editor { + selections: state.selections, + pending_selection: state.pending_selection, + scroll_top_anchor: state.scroll_top_anchor, + scroll_x: state.scroll_x, + scroll_y: state.scroll_y, + ..Default::default() + }), + cx, + ) + })) + } else { + None + } + }) + .ok_or_else(|| anyhow!("window was closed"))?; + + if let Some(task) = task { + task.await?; + } + + Ok(this) + })) + } + + fn add_event_to_update_proto( + &self, + event: &Self::Event, + update: &mut Option, + cx: &AppContext, + ) -> bool { + self.editor + .read(cx) + .add_event_to_update_proto(event, update, cx) + } + + fn apply_update_proto( + &mut self, + project: &ModelHandle, + message: proto::update_view::Variant, + cx: &mut ViewContext, + ) -> gpui::Task> { + self.editor.update(cx, |editor, cx| { + editor.apply_update_proto(project, message, cx) + }) + } + + fn set_leader_peer_id(&mut self, leader_peer_id: Option, cx: &mut ViewContext) { + self.editor.update(cx, |editor, cx| { + editor.set_leader_peer_id(leader_peer_id, cx) + }) + } + + fn should_unfollow_on_event(event: &Self::Event, cx: &AppContext) -> bool { + Editor::should_unfollow_on_event(event, cx) + } + + fn is_project_item(&self, _cx: &AppContext) -> bool { + false + } +} + +struct ChannelBufferCollaborationHub(ModelHandle); + +impl CollaborationHub for ChannelBufferCollaborationHub { + fn collaborators<'a>(&self, cx: &'a AppContext) -> &'a HashMap { + self.0.read(cx).collaborators() + } + + fn user_participant_indices<'a>( + &self, + cx: &'a AppContext, + ) -> &'a HashMap { + self.0.read(cx).user_store().read(cx).participant_indices() + } +} diff --git a/crates/collab_ui2/src/chat_panel.rs b/crates/collab_ui2/src/chat_panel.rs new file mode 100644 index 0000000000000000000000000000000000000000..5a4dafb6d4179da42ffaa0ef9b2aed0f19144285 --- /dev/null +++ b/crates/collab_ui2/src/chat_panel.rs @@ -0,0 +1,983 @@ +use crate::{ + channel_view::ChannelView, is_channels_feature_enabled, render_avatar, ChatPanelSettings, +}; +use anyhow::Result; +use call::ActiveCall; +use channel::{ChannelChat, ChannelChatEvent, ChannelMessageId, ChannelStore}; +use client::Client; +use collections::HashMap; +use db::kvp::KEY_VALUE_STORE; +use editor::Editor; +use gpui::{ + actions, + elements::*, + platform::{CursorStyle, MouseButton}, + serde_json, + views::{ItemType, Select, SelectStyle}, + AnyViewHandle, AppContext, AsyncAppContext, Entity, ModelHandle, Subscription, Task, View, + ViewContext, ViewHandle, WeakViewHandle, +}; +use language::LanguageRegistry; +use menu::Confirm; +use message_editor::MessageEditor; +use project::Fs; +use rich_text::RichText; +use serde::{Deserialize, Serialize}; +use settings::SettingsStore; +use std::sync::Arc; +use theme::{IconButton, Theme}; +use time::{OffsetDateTime, UtcOffset}; +use util::{ResultExt, TryFutureExt}; +use workspace::{ + dock::{DockPosition, Panel}, + Workspace, +}; + +mod message_editor; + +const MESSAGE_LOADING_THRESHOLD: usize = 50; +const CHAT_PANEL_KEY: &'static str = "ChatPanel"; + +pub struct ChatPanel { + client: Arc, + channel_store: ModelHandle, + languages: Arc, + active_chat: Option<(ModelHandle, Subscription)>, + message_list: ListState, + input_editor: ViewHandle, + channel_select: ViewHandle, + ) -> AnyElement, - local_timezone: UtcOffset, - fs: Arc, - width: Option, - active: bool, - pending_serialization: Task>, - subscriptions: Vec, - workspace: WeakViewHandle, - is_scrolled_to_bottom: bool, - has_focus: bool, - markdown_data: HashMap, -} - -#[derive(Serialize, Deserialize)] -struct SerializedChatPanel { - width: Option, -} - -#[derive(Debug)] -pub enum Event { - DockPositionChanged, - Focus, - Dismissed, -} - -actions!( - chat_panel, - [LoadMoreMessages, ToggleFocus, OpenChannelNotes, JoinCall] -); - -pub fn init(cx: &mut AppContext) { - cx.add_action(ChatPanel::send); - cx.add_action(ChatPanel::load_more_messages); - cx.add_action(ChatPanel::open_notes); - cx.add_action(ChatPanel::join_call); -} - -impl ChatPanel { - pub fn new(workspace: &mut Workspace, cx: &mut ViewContext) -> ViewHandle { - let fs = workspace.app_state().fs.clone(); - let client = workspace.app_state().client.clone(); - let channel_store = ChannelStore::global(cx); - let languages = workspace.app_state().languages.clone(); - - let input_editor = cx.add_view(|cx| { - MessageEditor::new( - languages.clone(), - channel_store.clone(), - cx.add_view(|cx| { - Editor::auto_height( - 4, - Some(Arc::new(|theme| theme.chat_panel.input_editor.clone())), - cx, - ) - }), - cx, - ) - }); - - let workspace_handle = workspace.weak_handle(); - - let channel_select = cx.add_view(|cx| { - let channel_store = channel_store.clone(); - let workspace = workspace_handle.clone(); - Select::new(0, cx, { - move |ix, item_type, is_hovered, cx| { - Self::render_channel_name( - &channel_store, - ix, - item_type, - is_hovered, - workspace, - cx, - ) - } - }) - .with_style(move |cx| { - let style = &theme::current(cx).chat_panel.channel_select; - SelectStyle { - header: Default::default(), - menu: style.menu, - } - }) - }); - - let mut message_list = - ListState::::new(0, Orientation::Bottom, 10., move |this, ix, cx| { - this.render_message(ix, cx) - }); - message_list.set_scroll_handler(|visible_range, count, this, cx| { - if visible_range.start < MESSAGE_LOADING_THRESHOLD { - this.load_more_messages(&LoadMoreMessages, cx); - } - this.is_scrolled_to_bottom = visible_range.end == count; - }); - - cx.add_view(|cx| { - let mut this = Self { - fs, - client, - channel_store, - languages, - active_chat: Default::default(), - pending_serialization: Task::ready(None), - message_list, - input_editor, - channel_select, - local_timezone: cx.platform().local_timezone(), - has_focus: false, - subscriptions: Vec::new(), - workspace: workspace_handle, - is_scrolled_to_bottom: true, - active: false, - width: None, - markdown_data: Default::default(), - }; - - let mut old_dock_position = this.position(cx); - this.subscriptions - .push( - cx.observe_global::(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(Event::DockPositionChanged); - } - cx.notify(); - }), - ); - - this.update_channel_count(cx); - cx.observe(&this.channel_store, |this, _, cx| { - this.update_channel_count(cx) - }) - .detach(); - - cx.observe(&this.channel_select, |this, channel_select, cx| { - let selected_ix = channel_select.read(cx).selected_index(); - - let selected_channel_id = this - .channel_store - .read(cx) - .channel_at(selected_ix) - .map(|e| e.id); - if let Some(selected_channel_id) = selected_channel_id { - this.select_channel(selected_channel_id, None, cx) - .detach_and_log_err(cx); - } - }) - .detach(); - - this - }) - } - - pub fn is_scrolled_to_bottom(&self) -> bool { - self.is_scrolled_to_bottom - } - - pub fn active_chat(&self) -> Option> { - self.active_chat.as_ref().map(|(chat, _)| chat.clone()) - } - - pub fn load( - workspace: WeakViewHandle, - cx: AsyncAppContext, - ) -> Task>> { - cx.spawn(|mut cx| async move { - let serialized_panel = if let Some(panel) = cx - .background() - .spawn(async move { KEY_VALUE_STORE.read_kvp(CHAT_PANEL_KEY) }) - .await - .log_err() - .flatten() - { - Some(serde_json::from_str::(&panel)?) - } else { - None - }; - - workspace.update(&mut cx, |workspace, cx| { - let panel = Self::new(workspace, cx); - if let Some(serialized_panel) = serialized_panel { - panel.update(cx, |panel, cx| { - panel.width = serialized_panel.width; - cx.notify(); - }); - } - panel - }) - }) - } - - fn serialize(&mut self, cx: &mut ViewContext) { - let width = self.width; - self.pending_serialization = cx.background().spawn( - async move { - KEY_VALUE_STORE - .write_kvp( - CHAT_PANEL_KEY.into(), - serde_json::to_string(&SerializedChatPanel { width })?, - ) - .await?; - anyhow::Ok(()) - } - .log_err(), - ); - } - - fn update_channel_count(&mut self, cx: &mut ViewContext) { - let channel_count = self.channel_store.read(cx).channel_count(); - self.channel_select.update(cx, |select, cx| { - select.set_item_count(channel_count, cx); - }); - } - - fn set_active_chat(&mut self, chat: ModelHandle, cx: &mut ViewContext) { - if self.active_chat.as_ref().map(|e| &e.0) != Some(&chat) { - let channel_id = chat.read(cx).channel_id; - { - self.markdown_data.clear(); - let chat = chat.read(cx); - self.message_list.reset(chat.message_count()); - - let channel_name = chat.channel(cx).map(|channel| channel.name.clone()); - self.input_editor.update(cx, |editor, cx| { - editor.set_channel(channel_id, channel_name, cx); - }); - }; - let subscription = cx.subscribe(&chat, Self::channel_did_change); - self.active_chat = Some((chat, subscription)); - self.acknowledge_last_message(cx); - self.channel_select.update(cx, |select, cx| { - if let Some(ix) = self.channel_store.read(cx).index_of_channel(channel_id) { - select.set_selected_index(ix, cx); - } - }); - cx.notify(); - } - } - - fn channel_did_change( - &mut self, - _: ModelHandle, - event: &ChannelChatEvent, - cx: &mut ViewContext, - ) { - match event { - ChannelChatEvent::MessagesUpdated { - old_range, - new_count, - } => { - self.message_list.splice(old_range.clone(), *new_count); - if self.active { - self.acknowledge_last_message(cx); - } - } - ChannelChatEvent::NewMessage { - channel_id, - message_id, - } => { - if !self.active { - self.channel_store.update(cx, |store, cx| { - store.new_message(*channel_id, *message_id, cx) - }) - } - } - } - cx.notify(); - } - - fn acknowledge_last_message(&mut self, cx: &mut ViewContext<'_, '_, ChatPanel>) { - if self.active && self.is_scrolled_to_bottom { - if let Some((chat, _)) = &self.active_chat { - chat.update(cx, |chat, cx| { - chat.acknowledge_last_message(cx); - }); - } - } - } - - fn render_channel(&self, cx: &mut ViewContext) -> AnyElement { - let theme = theme::current(cx); - Flex::column() - .with_child( - ChildView::new(&self.channel_select, cx) - .contained() - .with_style(theme.chat_panel.channel_select.container), - ) - .with_child(self.render_active_channel_messages(&theme)) - .with_child(self.render_input_box(&theme, cx)) - .into_any() - } - - fn render_active_channel_messages(&self, theme: &Arc) -> AnyElement { - let messages = if self.active_chat.is_some() { - List::new(self.message_list.clone()) - .contained() - .with_style(theme.chat_panel.list) - .into_any() - } else { - Empty::new().into_any() - }; - - messages.flex(1., true).into_any() - } - - fn render_message(&mut self, ix: usize, cx: &mut ViewContext) -> AnyElement { - let (message, is_continuation, is_last, is_admin) = self - .active_chat - .as_ref() - .unwrap() - .0 - .update(cx, |active_chat, cx| { - let is_admin = self - .channel_store - .read(cx) - .is_channel_admin(active_chat.channel_id); - - let last_message = active_chat.message(ix.saturating_sub(1)); - let this_message = active_chat.message(ix).clone(); - let is_continuation = last_message.id != this_message.id - && this_message.sender.id == last_message.sender.id; - - if let ChannelMessageId::Saved(id) = this_message.id { - if this_message - .mentions - .iter() - .any(|(_, user_id)| Some(*user_id) == self.client.user_id()) - { - active_chat.acknowledge_message(id); - } - } - - ( - this_message, - is_continuation, - active_chat.message_count() == ix + 1, - is_admin, - ) - }); - - let is_pending = message.is_pending(); - let theme = theme::current(cx); - let text = self.markdown_data.entry(message.id).or_insert_with(|| { - Self::render_markdown_with_mentions(&self.languages, self.client.id(), &message) - }); - - let now = OffsetDateTime::now_utc(); - - let style = if is_pending { - &theme.chat_panel.pending_message - } else if is_continuation { - &theme.chat_panel.continuation_message - } else { - &theme.chat_panel.message - }; - - let belongs_to_user = Some(message.sender.id) == self.client.user_id(); - let message_id_to_remove = if let (ChannelMessageId::Saved(id), true) = - (message.id, belongs_to_user || is_admin) - { - Some(id) - } else { - None - }; - - enum MessageBackgroundHighlight {} - MouseEventHandler::new::(ix, cx, |state, cx| { - let container = style.style_for(state); - if is_continuation { - Flex::row() - .with_child( - text.element( - theme.editor.syntax.clone(), - theme.chat_panel.rich_text.clone(), - cx, - ) - .flex(1., true), - ) - .with_child(render_remove(message_id_to_remove, cx, &theme)) - .contained() - .with_style(*container) - .with_margin_bottom(if is_last { - theme.chat_panel.last_message_bottom_spacing - } else { - 0. - }) - .into_any() - } else { - Flex::column() - .with_child( - Flex::row() - .with_child( - Flex::row() - .with_child(render_avatar( - message.sender.avatar.clone(), - &theme.chat_panel.avatar, - theme.chat_panel.avatar_container, - )) - .with_child( - Label::new( - message.sender.github_login.clone(), - theme.chat_panel.message_sender.text.clone(), - ) - .contained() - .with_style(theme.chat_panel.message_sender.container), - ) - .with_child( - Label::new( - format_timestamp( - message.timestamp, - now, - self.local_timezone, - ), - theme.chat_panel.message_timestamp.text.clone(), - ) - .contained() - .with_style(theme.chat_panel.message_timestamp.container), - ) - .align_children_center() - .flex(1., true), - ) - .with_child(render_remove(message_id_to_remove, cx, &theme)) - .align_children_center(), - ) - .with_child( - Flex::row() - .with_child( - text.element( - theme.editor.syntax.clone(), - theme.chat_panel.rich_text.clone(), - cx, - ) - .flex(1., true), - ) - // Add a spacer to make everything line up - .with_child(render_remove(None, cx, &theme)), - ) - .contained() - .with_style(*container) - .with_margin_bottom(if is_last { - theme.chat_panel.last_message_bottom_spacing - } else { - 0. - }) - .into_any() - } - }) - .into_any() - } - - fn render_markdown_with_mentions( - language_registry: &Arc, - current_user_id: u64, - message: &channel::ChannelMessage, - ) -> RichText { - let mentions = message - .mentions - .iter() - .map(|(range, user_id)| rich_text::Mention { - range: range.clone(), - is_self_mention: *user_id == current_user_id, - }) - .collect::>(); - - rich_text::render_markdown(message.body.clone(), &mentions, language_registry, None) - } - - fn render_input_box(&self, theme: &Arc, cx: &AppContext) -> AnyElement { - ChildView::new(&self.input_editor, cx) - .contained() - .with_style(theme.chat_panel.input_editor.container) - .into_any() - } - - fn render_channel_name( - channel_store: &ModelHandle, - ix: usize, - item_type: ItemType, - is_hovered: bool, - workspace: WeakViewHandle, - cx: &mut ViewContext { - let theme = theme::current(cx); - let tooltip_style = &theme.tooltip; - let theme = &theme.chat_panel; - let style = match (&item_type, is_hovered) { - (ItemType::Header, _) => &theme.channel_select.header, - (ItemType::Selected, _) => &theme.channel_select.active_item, - (ItemType::Unselected, false) => &theme.channel_select.item, - (ItemType::Unselected, true) => &theme.channel_select.hovered_item, - }; - - let channel = &channel_store.read(cx).channel_at(ix).unwrap(); - let channel_id = channel.id; - - let mut row = Flex::row() - .with_child( - Label::new("#".to_string(), style.hash.text.clone()) - .contained() - .with_style(style.hash.container), - ) - .with_child(Label::new(channel.name.clone(), style.name.clone())); - - if matches!(item_type, ItemType::Header) { - row.add_children([ - MouseEventHandler::new::(0, cx, |mouse_state, _| { - render_icon_button(theme.icon_button.style_for(mouse_state), "icons/file.svg") - }) - .on_click(MouseButton::Left, move |_, _, cx| { - if let Some(workspace) = workspace.upgrade(cx) { - ChannelView::open(channel_id, workspace, cx).detach(); - } - }) - .with_tooltip::( - channel_id as usize, - "Open Notes", - Some(Box::new(OpenChannelNotes)), - tooltip_style.clone(), - cx, - ) - .flex_float(), - MouseEventHandler::new::(0, cx, |mouse_state, _| { - render_icon_button( - theme.icon_button.style_for(mouse_state), - "icons/speaker-loud.svg", - ) - }) - .on_click(MouseButton::Left, move |_, _, cx| { - ActiveCall::global(cx) - .update(cx, |call, cx| call.join_channel(channel_id, cx)) - .detach_and_log_err(cx); - }) - .with_tooltip::( - channel_id as usize, - "Join Call", - Some(Box::new(JoinCall)), - tooltip_style.clone(), - cx, - ) - .flex_float(), - ]); - } - - row.align_children_center() - .contained() - .with_style(style.container) - .into_any() - } - - fn render_sign_in_prompt( - &self, - theme: &Arc, - cx: &mut ViewContext, - ) -> AnyElement { - enum SignInPromptLabel {} - - MouseEventHandler::new::(0, cx, |mouse_state, _| { - Label::new( - "Sign in to use chat".to_string(), - theme - .chat_panel - .sign_in_prompt - .style_for(mouse_state) - .clone(), - ) - }) - .with_cursor_style(CursorStyle::PointingHand) - .on_click(MouseButton::Left, move |_, this, cx| { - let client = this.client.clone(); - cx.spawn(|this, mut cx| async move { - if client - .authenticate_and_connect(true, &cx) - .log_err() - .await - .is_some() - { - this.update(&mut cx, |this, cx| { - if cx.handle().is_focused(cx) { - cx.focus(&this.input_editor); - } - }) - .ok(); - } - }) - .detach(); - }) - .aligned() - .into_any() - } - - fn send(&mut self, _: &Confirm, cx: &mut ViewContext) { - if let Some((chat, _)) = self.active_chat.as_ref() { - let message = self - .input_editor - .update(cx, |editor, cx| editor.take_message(cx)); - - if let Some(task) = chat - .update(cx, |chat, cx| chat.send_message(message, cx)) - .log_err() - { - task.detach(); - } - } - } - - fn remove_message(&mut self, id: u64, cx: &mut ViewContext) { - if let Some((chat, _)) = self.active_chat.as_ref() { - chat.update(cx, |chat, cx| chat.remove_message(id, cx).detach()) - } - } - - fn load_more_messages(&mut self, _: &LoadMoreMessages, cx: &mut ViewContext) { - if let Some((chat, _)) = self.active_chat.as_ref() { - chat.update(cx, |channel, cx| { - if let Some(task) = channel.load_more_messages(cx) { - task.detach(); - } - }) - } - } - - pub fn select_channel( - &mut self, - selected_channel_id: u64, - scroll_to_message_id: Option, - cx: &mut ViewContext, - ) -> Task> { - let open_chat = self - .active_chat - .as_ref() - .and_then(|(chat, _)| { - (chat.read(cx).channel_id == selected_channel_id) - .then(|| Task::ready(anyhow::Ok(chat.clone()))) - }) - .unwrap_or_else(|| { - self.channel_store.update(cx, |store, cx| { - store.open_channel_chat(selected_channel_id, cx) - }) - }); - - cx.spawn(|this, mut cx| async move { - let chat = open_chat.await?; - this.update(&mut cx, |this, cx| { - this.set_active_chat(chat.clone(), cx); - })?; - - if let Some(message_id) = scroll_to_message_id { - if let Some(item_ix) = - ChannelChat::load_history_since_message(chat.clone(), message_id, cx.clone()) - .await - { - this.update(&mut cx, |this, cx| { - if this.active_chat.as_ref().map_or(false, |(c, _)| *c == chat) { - this.message_list.scroll_to(ListOffset { - item_ix, - offset_in_item: 0., - }); - cx.notify(); - } - })?; - } - } - - Ok(()) - }) - } - - fn open_notes(&mut self, _: &OpenChannelNotes, cx: &mut ViewContext) { - if let Some((chat, _)) = &self.active_chat { - let channel_id = chat.read(cx).channel_id; - if let Some(workspace) = self.workspace.upgrade(cx) { - ChannelView::open(channel_id, workspace, cx).detach(); - } - } - } - - fn join_call(&mut self, _: &JoinCall, cx: &mut ViewContext) { - if let Some((chat, _)) = &self.active_chat { - let channel_id = chat.read(cx).channel_id; - ActiveCall::global(cx) - .update(cx, |call, cx| call.join_channel(channel_id, cx)) - .detach_and_log_err(cx); - } - } -} - -fn render_remove( - message_id_to_remove: Option, - cx: &mut ViewContext<'_, '_, ChatPanel>, - theme: &Arc, -) -> AnyElement { - enum DeleteMessage {} - - message_id_to_remove - .map(|id| { - MouseEventHandler::new::(id as usize, cx, |mouse_state, _| { - let button_style = theme.chat_panel.icon_button.style_for(mouse_state); - render_icon_button(button_style, "icons/x.svg") - .aligned() - .into_any() - }) - .with_padding(Padding::uniform(2.)) - .with_cursor_style(CursorStyle::PointingHand) - .on_click(MouseButton::Left, move |_, this, cx| { - this.remove_message(id, cx); - }) - .flex_float() - .into_any() - }) - .unwrap_or_else(|| { - let style = theme.chat_panel.icon_button.default; - - Empty::new() - .constrained() - .with_width(style.icon_width) - .aligned() - .constrained() - .with_width(style.button_width) - .with_height(style.button_width) - .contained() - .with_uniform_padding(2.) - .flex_float() - .into_any() - }) -} - -impl Entity for ChatPanel { - type Event = Event; -} - -impl View for ChatPanel { - fn ui_name() -> &'static str { - "ChatPanel" - } - - fn render(&mut self, cx: &mut ViewContext) -> AnyElement { - let theme = theme::current(cx); - let element = if self.client.user_id().is_some() { - self.render_channel(cx) - } else { - self.render_sign_in_prompt(&theme, cx) - }; - element - .contained() - .with_style(theme.chat_panel.container) - .constrained() - .with_min_width(150.) - .into_any() - } - - fn focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext) { - self.has_focus = true; - if matches!( - *self.client.status().borrow(), - client::Status::Connected { .. } - ) { - let editor = self.input_editor.read(cx).editor.clone(); - cx.focus(&editor); - } - } - - fn focus_out(&mut self, _: AnyViewHandle, _: &mut ViewContext) { - self.has_focus = false; - } -} - -impl Panel for ChatPanel { - fn position(&self, cx: &gpui::WindowContext) -> DockPosition { - settings::get::(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) { - settings::update_settings_file::(self.fs.clone(), cx, move |settings| { - settings.dock = Some(position) - }); - } - - fn size(&self, cx: &gpui::WindowContext) -> f32 { - self.width - .unwrap_or_else(|| settings::get::(cx).default_width) - } - - fn set_size(&mut self, size: Option, cx: &mut ViewContext) { - self.width = size; - self.serialize(cx); - cx.notify(); - } - - fn set_active(&mut self, active: bool, cx: &mut ViewContext) { - self.active = active; - if active { - self.acknowledge_last_message(cx); - if !is_channels_feature_enabled(cx) { - cx.emit(Event::Dismissed); - } - } - } - - fn icon_path(&self, cx: &gpui::WindowContext) -> Option<&'static str> { - (settings::get::(cx).button && is_channels_feature_enabled(cx)) - .then(|| "icons/conversations.svg") - } - - fn icon_tooltip(&self) -> (String, Option>) { - ("Chat Panel".to_string(), Some(Box::new(ToggleFocus))) - } - - fn should_change_position_on_event(event: &Self::Event) -> bool { - matches!(event, Event::DockPositionChanged) - } - - fn should_close_on_event(event: &Self::Event) -> bool { - matches!(event, Event::Dismissed) - } - - fn has_focus(&self, _cx: &gpui::WindowContext) -> bool { - self.has_focus - } - - fn is_focus_event(event: &Self::Event) -> bool { - matches!(event, Event::Focus) - } -} - -fn format_timestamp( - mut timestamp: OffsetDateTime, - mut now: OffsetDateTime, - local_timezone: UtcOffset, -) -> String { - timestamp = timestamp.to_offset(local_timezone); - now = now.to_offset(local_timezone); - - let today = now.date(); - let date = timestamp.date(); - let mut hour = timestamp.hour(); - let mut part = "am"; - if hour > 12 { - hour -= 12; - part = "pm"; - } - if date == today { - format!("{:02}:{:02}{}", hour, timestamp.minute(), part) - } else if date.next_day() == Some(today) { - format!("yesterday at {:02}:{:02}{}", hour, timestamp.minute(), part) - } else { - format!("{:02}/{}/{}", date.month() as u32, date.day(), date.year()) - } -} - -fn render_icon_button(style: &IconButton, svg_path: &'static str) -> impl Element { - Svg::new(svg_path) - .with_color(style.color) - .constrained() - .with_width(style.icon_width) - .aligned() - .constrained() - .with_width(style.button_width) - .with_height(style.button_width) - .contained() - .with_style(style.container) -} - -#[cfg(test)] -mod tests { - use super::*; - use gpui::fonts::HighlightStyle; - use pretty_assertions::assert_eq; - use rich_text::{BackgroundKind, Highlight, RenderedRegion}; - use util::test::marked_text_ranges; - - #[gpui::test] - fn test_render_markdown_with_mentions() { - let language_registry = Arc::new(LanguageRegistry::test()); - let (body, ranges) = marked_text_ranges("*hi*, «@abc», let's **call** «@fgh»", false); - let message = channel::ChannelMessage { - id: ChannelMessageId::Saved(0), - body, - timestamp: OffsetDateTime::now_utc(), - sender: Arc::new(client::User { - github_login: "fgh".into(), - avatar: None, - id: 103, - }), - nonce: 5, - mentions: vec![(ranges[0].clone(), 101), (ranges[1].clone(), 102)], - }; - - let message = ChatPanel::render_markdown_with_mentions(&language_registry, 102, &message); - - // Note that the "'" was replaced with ’ due to smart punctuation. - let (body, ranges) = marked_text_ranges("«hi», «@abc», let’s «call» «@fgh»", false); - assert_eq!(message.text, body); - assert_eq!( - message.highlights, - vec![ - ( - ranges[0].clone(), - HighlightStyle { - italic: Some(true), - ..Default::default() - } - .into() - ), - (ranges[1].clone(), Highlight::Mention), - ( - ranges[2].clone(), - HighlightStyle { - weight: Some(gpui::fonts::Weight::BOLD), - ..Default::default() - } - .into() - ), - (ranges[3].clone(), Highlight::SelfMention) - ] - ); - assert_eq!( - message.regions, - vec![ - RenderedRegion { - background_kind: Some(BackgroundKind::Mention), - link_url: None - }, - RenderedRegion { - background_kind: Some(BackgroundKind::SelfMention), - link_url: None - }, - ] - ); - } -} +// use crate::{ +// channel_view::ChannelView, is_channels_feature_enabled, render_avatar, ChatPanelSettings, +// }; +// use anyhow::Result; +// use call::ActiveCall; +// use channel::{ChannelChat, ChannelChatEvent, ChannelMessageId, ChannelStore}; +// use client::Client; +// use collections::HashMap; +// use db::kvp::KEY_VALUE_STORE; +// use editor::Editor; +// use gpui::{ +// actions, +// elements::*, +// platform::{CursorStyle, MouseButton}, +// serde_json, +// views::{ItemType, Select, SelectStyle}, +// AnyViewHandle, AppContext, AsyncAppContext, Entity, ModelHandle, Subscription, Task, View, +// ViewContext, ViewHandle, WeakViewHandle, +// }; +// use language::LanguageRegistry; +// use menu::Confirm; +// use message_editor::MessageEditor; +// use project::Fs; +// use rich_text::RichText; +// use serde::{Deserialize, Serialize}; +// use settings::SettingsStore; +// use std::sync::Arc; +// use theme::{IconButton, Theme}; +// use time::{OffsetDateTime, UtcOffset}; +// use util::{ResultExt, TryFutureExt}; +// use workspace::{ +// dock::{DockPosition, Panel}, +// Workspace, +// }; + +// mod message_editor; + +// const MESSAGE_LOADING_THRESHOLD: usize = 50; +// const CHAT_PANEL_KEY: &'static str = "ChatPanel"; + +// pub struct ChatPanel { +// client: Arc, +// channel_store: ModelHandle, +// languages: Arc, +// active_chat: Option<(ModelHandle, Subscription)>, +// message_list: ListState, +// input_editor: ViewHandle, +// channel_select: ViewHandle, +// ) -> AnyElement