diff --git a/Cargo.lock b/Cargo.lock index 69f402f2e7..de80a9a7b3 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 0000000000..8c48e09846 --- /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 0000000000..fe46f3bb3e --- /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 0000000000..5a4dafb6d4 --- /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