Remove 2 suffix for welcome, vcs_menu, quick_action_bar, collab_ui

Co-authored-by: Mikayla <mikayla@zed.dev>
This commit is contained in:
Max Brunsfeld 2024-01-03 10:30:52 -08:00
parent 7986ee18cd
commit 2b8822fd08
49 changed files with 3529 additions and 14036 deletions

100
Cargo.lock generated
View file

@ -1762,7 +1762,7 @@ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
"async-tungstenite", "async-tungstenite",
"audio2", "audio",
"axum", "axum",
"axum-extra", "axum-extra",
"base64 0.13.1", "base64 0.13.1",
@ -1771,7 +1771,7 @@ dependencies = [
"clap 3.2.25", "clap 3.2.25",
"client2", "client2",
"clock", "clock",
"collab_ui2", "collab_ui",
"collections", "collections",
"ctor", "ctor",
"dashmap", "dashmap",
@ -1831,53 +1831,6 @@ dependencies = [
[[package]] [[package]]
name = "collab_ui" name = "collab_ui"
version = "0.1.0" version = "0.1.0"
dependencies = [
"anyhow",
"auto_update",
"call",
"channel",
"client",
"clock",
"collections",
"context_menu",
"db",
"drag_and_drop",
"editor",
"feature_flags",
"feedback",
"futures 0.3.28",
"fuzzy",
"gpui",
"language",
"lazy_static",
"log",
"menu",
"notifications",
"picker",
"postage",
"pretty_assertions",
"project",
"recent_projects",
"rich_text",
"rpc",
"schemars",
"serde",
"serde_derive",
"settings",
"smallvec",
"theme",
"theme_selector",
"time",
"tree-sitter-markdown",
"util",
"vcs_menu",
"workspace",
"zed-actions",
]
[[package]]
name = "collab_ui2"
version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"auto_update2", "auto_update2",
@ -1916,7 +1869,7 @@ dependencies = [
"tree-sitter-markdown", "tree-sitter-markdown",
"ui2", "ui2",
"util", "util",
"vcs_menu2", "vcs_menu",
"workspace2", "workspace2",
"zed_actions2", "zed_actions2",
] ]
@ -7010,7 +6963,7 @@ dependencies = [
] ]
[[package]] [[package]]
name = "quick_action_bar2" name = "quick_action_bar"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"assistant2", "assistant2",
@ -10604,20 +10557,6 @@ checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]] [[package]]
name = "vcs_menu" name = "vcs_menu"
version = "0.1.0" version = "0.1.0"
dependencies = [
"anyhow",
"fs",
"fuzzy",
"gpui",
"picker",
"theme",
"util",
"workspace",
]
[[package]]
name = "vcs_menu2"
version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"fs2", "fs2",
@ -11093,31 +11032,6 @@ checksum = "9193164d4de03a926d909d3bc7c30543cecb35400c02114792c2cae20d5e2dbb"
[[package]] [[package]]
name = "welcome" name = "welcome"
version = "0.1.0" version = "0.1.0"
dependencies = [
"anyhow",
"client",
"db",
"editor",
"fs",
"fuzzy",
"gpui",
"install_cli",
"log",
"picker",
"project",
"schemars",
"serde",
"settings",
"theme",
"theme_selector",
"util",
"vim",
"workspace",
]
[[package]]
name = "welcome2"
version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"client2", "client2",
@ -11557,7 +11471,7 @@ dependencies = [
"chrono", "chrono",
"cli", "cli",
"client2", "client2",
"collab_ui2", "collab_ui",
"collections", "collections",
"command_palette2", "command_palette2",
"copilot2", "copilot2",
@ -11598,7 +11512,7 @@ dependencies = [
"project2", "project2",
"project_panel2", "project_panel2",
"project_symbols2", "project_symbols2",
"quick_action_bar2", "quick_action_bar",
"rand 0.8.5", "rand 0.8.5",
"recent_projects2", "recent_projects2",
"regex", "regex",
@ -11661,7 +11575,7 @@ dependencies = [
"util", "util",
"uuid 1.4.1", "uuid 1.4.1",
"vim2", "vim2",
"welcome2", "welcome",
"workspace2", "workspace2",
"zed_actions2", "zed_actions2",
] ]

View file

@ -22,7 +22,6 @@ members = [
"crates/collab", "crates/collab",
"crates/collab2", "crates/collab2",
"crates/collab_ui", "crates/collab_ui",
"crates/collab_ui2",
"crates/collections", "crates/collections",
"crates/command_palette", "crates/command_palette",
"crates/command_palette2", "crates/command_palette2",
@ -91,7 +90,7 @@ members = [
"crates/project_panel2", "crates/project_panel2",
"crates/project_symbols", "crates/project_symbols",
"crates/project_symbols2", "crates/project_symbols2",
"crates/quick_action_bar2", "crates/quick_action_bar",
"crates/recent_projects", "crates/recent_projects",
"crates/recent_projects2", "crates/recent_projects2",
"crates/rope", "crates/rope",
@ -123,10 +122,8 @@ members = [
"crates/story", "crates/story",
"crates/vim", "crates/vim",
"crates/vcs_menu", "crates/vcs_menu",
"crates/vcs_menu2",
"crates/workspace2", "crates/workspace2",
"crates/welcome", "crates/welcome",
"crates/welcome2",
"crates/xtask", "crates/xtask",
"crates/zed", "crates/zed",
"crates/zed-actions", "crates/zed-actions",

View file

@ -60,7 +60,7 @@ tracing-subscriber = { version = "0.3.11", features = ["env-filter", "json"] }
uuid.workspace = true uuid.workspace = true
[dev-dependencies] [dev-dependencies]
audio = { package = "audio2", path = "../audio2" } audio = { path = "../audio" }
collections = { path = "../collections", features = ["test-support"] } collections = { path = "../collections", features = ["test-support"] }
gpui = { package = "gpui2", path = "../gpui2", features = ["test-support"] } gpui = { package = "gpui2", path = "../gpui2", features = ["test-support"] }
call = { package = "call2", path = "../call2", features = ["test-support"] } call = { package = "call2", path = "../call2", features = ["test-support"] }
@ -81,7 +81,7 @@ settings = { package = "settings2", path = "../settings2", features = ["test-sup
theme = { package = "theme2", path = "../theme2" } theme = { package = "theme2", path = "../theme2" }
workspace = { package = "workspace2", path = "../workspace2", features = ["test-support"] } workspace = { package = "workspace2", path = "../workspace2", features = ["test-support"] }
collab_ui = { path = "../collab_ui2", package = "collab_ui2", features = ["test-support"] } collab_ui = { path = "../collab_ui", features = ["test-support"] }
async-trait.workspace = true async-trait.workspace = true
pretty_assertions.workspace = true pretty_assertions.workspace = true

View file

@ -22,35 +22,36 @@ test-support = [
] ]
[dependencies] [dependencies]
auto_update = { path = "../auto_update" } auto_update = { package = "auto_update2", path = "../auto_update2" }
db = { path = "../db" } db = { package = "db2", path = "../db2" }
call = { path = "../call" } call = { package = "call2", path = "../call2" }
client = { path = "../client" } client = { package = "client2", path = "../client2" }
channel = { path = "../channel" } channel = { package = "channel2", path = "../channel2" }
clock = { path = "../clock" } clock = { path = "../clock" }
collections = { path = "../collections" } collections = { path = "../collections" }
context_menu = { path = "../context_menu" } # context_menu = { path = "../context_menu" }
drag_and_drop = { path = "../drag_and_drop" } # drag_and_drop = { path = "../drag_and_drop" }
editor = { path = "../editor" } editor = { package="editor2", path = "../editor2" }
feedback = { path = "../feedback" } feedback = { package = "feedback2", path = "../feedback2" }
fuzzy = { path = "../fuzzy" } fuzzy = { package = "fuzzy2", path = "../fuzzy2" }
gpui = { path = "../gpui" } gpui = { package = "gpui2", path = "../gpui2" }
language = { path = "../language" } language = { package = "language2", path = "../language2" }
menu = { path = "../menu" } menu = { package = "menu2", path = "../menu2" }
notifications = { path = "../notifications" } notifications = { package = "notifications2", path = "../notifications2" }
rich_text = { path = "../rich_text" } rich_text = { package = "rich_text2", path = "../rich_text2" }
picker = { path = "../picker" } picker = { package = "picker2", path = "../picker2" }
project = { path = "../project" } project = { package = "project2", path = "../project2" }
recent_projects = { path = "../recent_projects" } recent_projects = { package = "recent_projects2", path = "../recent_projects2" }
rpc = { path = "../rpc" } rpc = { package ="rpc2", path = "../rpc2" }
settings = { path = "../settings" } settings = { package = "settings2", path = "../settings2" }
feature_flags = {path = "../feature_flags"} feature_flags = { package = "feature_flags2", path = "../feature_flags2"}
theme = { path = "../theme" } theme = { package = "theme2", path = "../theme2" }
theme_selector = { path = "../theme_selector" } theme_selector = { package = "theme_selector2", path = "../theme_selector2" }
vcs_menu = { path = "../vcs_menu" } vcs_menu = { path = "../vcs_menu" }
ui = { package = "ui2", path = "../ui2" }
util = { path = "../util" } util = { path = "../util" }
workspace = { path = "../workspace" } workspace = { package = "workspace2", path = "../workspace2" }
zed-actions = {path = "../zed-actions"} zed-actions = { package="zed_actions2", path = "../zed_actions2"}
anyhow.workspace = true anyhow.workspace = true
futures.workspace = true futures.workspace = true
@ -64,17 +65,17 @@ time.workspace = true
smallvec.workspace = true smallvec.workspace = true
[dev-dependencies] [dev-dependencies]
call = { path = "../call", features = ["test-support"] } call = { package = "call2", path = "../call2", features = ["test-support"] }
client = { path = "../client", features = ["test-support"] } client = { package = "client2", path = "../client2", features = ["test-support"] }
collections = { path = "../collections", features = ["test-support"] } collections = { path = "../collections", features = ["test-support"] }
editor = { path = "../editor", features = ["test-support"] } editor = { package = "editor2", path = "../editor2", features = ["test-support"] }
gpui = { path = "../gpui", features = ["test-support"] } gpui = { package = "gpui2", path = "../gpui2", features = ["test-support"] }
notifications = { path = "../notifications", features = ["test-support"] } notifications = { package = "notifications2", path = "../notifications2", features = ["test-support"] }
project = { path = "../project", features = ["test-support"] } project = { package = "project2", path = "../project2", features = ["test-support"] }
rpc = { path = "../rpc", features = ["test-support"] } rpc = { package = "rpc2", path = "../rpc2", features = ["test-support"] }
settings = { path = "../settings", features = ["test-support"] } settings = { package = "settings2", path = "../settings2", features = ["test-support"] }
util = { path = "../util", features = ["test-support"] } util = { path = "../util", features = ["test-support"] }
workspace = { path = "../workspace", features = ["test-support"] } workspace = { package = "workspace2", path = "../workspace2", features = ["test-support"] }
pretty_assertions.workspace = true pretty_assertions.workspace = true
tree-sitter-markdown.workspace = true tree-sitter-markdown.workspace = true

View file

@ -1,4 +1,4 @@
use anyhow::{anyhow, Result}; use anyhow::Result;
use call::report_call_event_for_channel; use call::report_call_event_for_channel;
use channel::{Channel, ChannelBuffer, ChannelBufferEvent, ChannelId, ChannelStore}; use channel::{Channel, ChannelBuffer, ChannelBufferEvent, ChannelId, ChannelStore};
use client::{ use client::{
@ -6,20 +6,18 @@ use client::{
Collaborator, ParticipantIndex, Collaborator, ParticipantIndex,
}; };
use collections::HashMap; use collections::HashMap;
use editor::{CollaborationHub, Editor}; use editor::{CollaborationHub, Editor, EditorEvent};
use gpui::{ use gpui::{
actions, actions, AnyElement, AnyView, AppContext, Entity as _, EventEmitter, FocusableView,
elements::{ChildView, Label}, IntoElement as _, Model, Pixels, Point, Render, Subscription, Task, View, ViewContext,
geometry::vector::Vector2F, VisualContext as _, WindowContext,
AnyElement, AnyViewHandle, AppContext, Element, Entity, ModelHandle, Subscription, Task, View,
ViewContext, ViewHandle,
}; };
use project::Project; use project::Project;
use smallvec::SmallVec;
use std::{ use std::{
any::{Any, TypeId}, any::{Any, TypeId},
sync::Arc, sync::Arc,
}; };
use ui::{prelude::*, Label};
use util::ResultExt; use util::ResultExt;
use workspace::{ use workspace::{
item::{FollowableItem, Item, ItemEvent, ItemHandle}, item::{FollowableItem, Item, ItemEvent, ItemHandle},
@ -28,17 +26,17 @@ use workspace::{
ItemNavHistory, Pane, SaveIntent, ViewId, Workspace, WorkspaceId, ItemNavHistory, Pane, SaveIntent, ViewId, Workspace, WorkspaceId,
}; };
actions!(channel_view, [Deploy]); actions!(collab, [Deploy]);
pub fn init(cx: &mut AppContext) { pub fn init(cx: &mut AppContext) {
register_followable_item::<ChannelView>(cx) register_followable_item::<ChannelView>(cx)
} }
pub struct ChannelView { pub struct ChannelView {
pub editor: ViewHandle<Editor>, pub editor: View<Editor>,
project: ModelHandle<Project>, project: Model<Project>,
channel_store: ModelHandle<ChannelStore>, channel_store: Model<ChannelStore>,
channel_buffer: ModelHandle<ChannelBuffer>, channel_buffer: Model<ChannelBuffer>,
remote_id: Option<ViewId>, remote_id: Option<ViewId>,
_editor_event_subscription: Subscription, _editor_event_subscription: Subscription,
} }
@ -46,9 +44,9 @@ pub struct ChannelView {
impl ChannelView { impl ChannelView {
pub fn open( pub fn open(
channel_id: ChannelId, channel_id: ChannelId,
workspace: ViewHandle<Workspace>, workspace: View<Workspace>,
cx: &mut AppContext, cx: &mut WindowContext,
) -> Task<Result<ViewHandle<Self>>> { ) -> Task<Result<View<Self>>> {
let pane = workspace.read(cx).active_pane().clone(); let pane = workspace.read(cx).active_pane().clone();
let channel_view = Self::open_in_pane(channel_id, pane.clone(), workspace.clone(), cx); let channel_view = Self::open_in_pane(channel_id, pane.clone(), workspace.clone(), cx);
cx.spawn(|mut cx| async move { cx.spawn(|mut cx| async move {
@ -61,17 +59,17 @@ impl ChannelView {
cx, cx,
); );
pane.add_item(Box::new(channel_view.clone()), true, true, None, cx); pane.add_item(Box::new(channel_view.clone()), true, true, None, cx);
}); })?;
anyhow::Ok(channel_view) anyhow::Ok(channel_view)
}) })
} }
pub fn open_in_pane( pub fn open_in_pane(
channel_id: ChannelId, channel_id: ChannelId,
pane: ViewHandle<Pane>, pane: View<Pane>,
workspace: ViewHandle<Workspace>, workspace: View<Workspace>,
cx: &mut AppContext, cx: &mut WindowContext,
) -> Task<Result<ViewHandle<Self>>> { ) -> Task<Result<View<Self>>> {
let workspace = workspace.read(cx); let workspace = workspace.read(cx);
let project = workspace.project().to_owned(); let project = workspace.project().to_owned();
let channel_store = ChannelStore::global(cx); let channel_store = ChannelStore::global(cx);
@ -91,7 +89,7 @@ impl ChannelView {
buffer.set_language(Some(markdown), cx); buffer.set_language(Some(markdown), cx);
} }
}) })
}); })?;
pane.update(&mut cx, |pane, cx| { pane.update(&mut cx, |pane, cx| {
let buffer_id = channel_buffer.read(cx).remote_id(cx); let buffer_id = channel_buffer.read(cx).remote_id(cx);
@ -107,7 +105,7 @@ impl ChannelView {
} }
} }
let view = cx.add_view(|cx| { let view = cx.new_view(|cx| {
let mut this = Self::new(project, channel_store, channel_buffer, cx); let mut this = Self::new(project, channel_store, channel_buffer, cx);
this.acknowledge_buffer_version(cx); this.acknowledge_buffer_version(cx);
this this
@ -117,7 +115,7 @@ impl ChannelView {
// replace that. // replace that.
if let Some(existing_item) = existing_view { if let Some(existing_item) = existing_view {
if let Some(ix) = pane.index_for_item(&existing_item) { if let Some(ix) = pane.index_for_item(&existing_item) {
pane.close_item_by_id(existing_item.id(), SaveIntent::Skip, cx) pane.close_item_by_id(existing_item.entity_id(), SaveIntent::Skip, cx)
.detach(); .detach();
pane.add_item(Box::new(view.clone()), true, true, Some(ix), cx); pane.add_item(Box::new(view.clone()), true, true, Some(ix), cx);
} }
@ -125,18 +123,17 @@ impl ChannelView {
view view
}) })
.ok_or_else(|| anyhow!("pane was dropped"))
}) })
} }
pub fn new( pub fn new(
project: ModelHandle<Project>, project: Model<Project>,
channel_store: ModelHandle<ChannelStore>, channel_store: Model<ChannelStore>,
channel_buffer: ModelHandle<ChannelBuffer>, channel_buffer: Model<ChannelBuffer>,
cx: &mut ViewContext<Self>, cx: &mut ViewContext<Self>,
) -> Self { ) -> Self {
let buffer = channel_buffer.read(cx).buffer(); let buffer = channel_buffer.read(cx).buffer();
let editor = cx.add_view(|cx| { let editor = cx.new_view(|cx| {
let mut editor = Editor::for_buffer(buffer, None, cx); let mut editor = Editor::for_buffer(buffer, None, cx);
editor.set_collaboration_hub(Box::new(ChannelBufferCollaborationHub( editor.set_collaboration_hub(Box::new(ChannelBufferCollaborationHub(
channel_buffer.clone(), channel_buffer.clone(),
@ -149,7 +146,8 @@ impl ChannelView {
); );
editor editor
}); });
let _editor_event_subscription = cx.subscribe(&editor, |_, _, e, cx| cx.emit(e.clone())); let _editor_event_subscription =
cx.subscribe(&editor, |_, _, e: &EditorEvent, cx| cx.emit(e.clone()));
cx.subscribe(&channel_buffer, Self::handle_channel_buffer_event) cx.subscribe(&channel_buffer, Self::handle_channel_buffer_event)
.detach(); .detach();
@ -170,7 +168,7 @@ impl ChannelView {
fn handle_channel_buffer_event( fn handle_channel_buffer_event(
&mut self, &mut self,
_: ModelHandle<ChannelBuffer>, _: Model<ChannelBuffer>,
event: &ChannelBufferEvent, event: &ChannelBufferEvent,
cx: &mut ViewContext<Self>, cx: &mut ViewContext<Self>,
) { ) {
@ -182,12 +180,12 @@ impl ChannelView {
ChannelBufferEvent::ChannelChanged => { ChannelBufferEvent::ChannelChanged => {
self.editor.update(cx, |editor, cx| { self.editor.update(cx, |editor, cx| {
editor.set_read_only(!self.channel(cx).is_some_and(|c| c.can_edit_notes())); editor.set_read_only(!self.channel(cx).is_some_and(|c| c.can_edit_notes()));
cx.emit(editor::Event::TitleChanged); cx.emit(editor::EditorEvent::TitleChanged);
cx.notify() cx.notify()
}); });
} }
ChannelBufferEvent::BufferEdited => { ChannelBufferEvent::BufferEdited => {
if cx.is_self_focused() || self.editor.is_focused(cx) { if self.editor.read(cx).is_focused(cx) {
self.acknowledge_buffer_version(cx); self.acknowledge_buffer_version(cx);
} else { } else {
self.channel_store.update(cx, |store, cx| { self.channel_store.update(cx, |store, cx| {
@ -205,7 +203,7 @@ impl ChannelView {
} }
} }
fn acknowledge_buffer_version(&mut self, cx: &mut ViewContext<'_, '_, ChannelView>) { fn acknowledge_buffer_version(&mut self, cx: &mut ViewContext<ChannelView>) {
self.channel_store.update(cx, |store, cx| { self.channel_store.update(cx, |store, cx| {
let channel_buffer = self.channel_buffer.read(cx); let channel_buffer = self.channel_buffer.read(cx);
store.acknowledge_notes_version( store.acknowledge_notes_version(
@ -221,49 +219,39 @@ impl ChannelView {
} }
} }
impl Entity for ChannelView { impl EventEmitter<EditorEvent> for ChannelView {}
type Event = editor::Event;
impl Render for ChannelView {
fn render(&mut self, _cx: &mut ViewContext<Self>) -> impl IntoElement {
self.editor.clone()
}
} }
impl View for ChannelView { impl FocusableView for ChannelView {
fn ui_name() -> &'static str { fn focus_handle(&self, cx: &AppContext) -> gpui::FocusHandle {
"ChannelView" self.editor.read(cx).focus_handle(cx)
}
fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
ChildView::new(self.editor.as_any(), cx).into_any()
}
fn focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
if cx.is_self_focused() {
self.acknowledge_buffer_version(cx);
cx.focus(self.editor.as_any())
}
} }
} }
impl Item for ChannelView { impl Item for ChannelView {
type Event = EditorEvent;
fn act_as_type<'a>( fn act_as_type<'a>(
&'a self, &'a self,
type_id: TypeId, type_id: TypeId,
self_handle: &'a ViewHandle<Self>, self_handle: &'a View<Self>,
_: &'a AppContext, _: &'a AppContext,
) -> Option<&'a AnyViewHandle> { ) -> Option<AnyView> {
if type_id == TypeId::of::<Self>() { if type_id == TypeId::of::<Self>() {
Some(self_handle) Some(self_handle.to_any())
} else if type_id == TypeId::of::<Editor>() { } else if type_id == TypeId::of::<Editor>() {
Some(&self.editor) Some(self.editor.to_any())
} else { } else {
None None
} }
} }
fn tab_content<V: 'static>( fn tab_content(&self, _: Option<usize>, selected: bool, cx: &WindowContext) -> AnyElement {
&self,
_: Option<usize>,
style: &theme::Tab,
cx: &gpui::AppContext,
) -> AnyElement<V> {
let label = if let Some(channel) = self.channel(cx) { let label = if let Some(channel) = self.channel(cx) {
match ( match (
channel.can_edit_notes(), channel.can_edit_notes(),
@ -276,16 +264,24 @@ impl Item for ChannelView {
} else { } else {
format!("channel notes (disconnected)") format!("channel notes (disconnected)")
}; };
Label::new(label, style.label.to_owned()).into_any() Label::new(label)
.color(if selected {
Color::Default
} else {
Color::Muted
})
.into_any_element()
} }
fn clone_on_split(&self, _: WorkspaceId, cx: &mut ViewContext<Self>) -> Option<Self> { fn clone_on_split(&self, _: WorkspaceId, cx: &mut ViewContext<Self>) -> Option<View<Self>> {
Some(Self::new( Some(cx.new_view(|cx| {
self.project.clone(), Self::new(
self.channel_store.clone(), self.project.clone(),
self.channel_buffer.clone(), self.channel_store.clone(),
cx, self.channel_buffer.clone(),
)) cx,
)
}))
} }
fn is_singleton(&self, _cx: &AppContext) -> bool { fn is_singleton(&self, _cx: &AppContext) -> bool {
@ -307,7 +303,7 @@ impl Item for ChannelView {
.update(cx, |editor, cx| Item::set_nav_history(editor, history, cx)) .update(cx, |editor, cx| Item::set_nav_history(editor, history, cx))
} }
fn as_searchable(&self, _: &ViewHandle<Self>) -> Option<Box<dyn SearchableItemHandle>> { fn as_searchable(&self, _: &View<Self>) -> Option<Box<dyn SearchableItemHandle>> {
Some(Box::new(self.editor.clone())) Some(Box::new(self.editor.clone()))
} }
@ -315,12 +311,12 @@ impl Item for ChannelView {
true true
} }
fn pixel_position_of_cursor(&self, cx: &AppContext) -> Option<Vector2F> { fn pixel_position_of_cursor(&self, cx: &AppContext) -> Option<Point<Pixels>> {
self.editor.read(cx).pixel_position_of_cursor(cx) self.editor.read(cx).pixel_position_of_cursor(cx)
} }
fn to_item_events(event: &Self::Event) -> SmallVec<[ItemEvent; 2]> { fn to_item_events(event: &EditorEvent, f: impl FnMut(ItemEvent)) {
editor::Editor::to_item_events(event) Editor::to_item_events(event, f)
} }
} }
@ -329,7 +325,7 @@ impl FollowableItem for ChannelView {
self.remote_id self.remote_id
} }
fn to_state_proto(&self, cx: &AppContext) -> Option<proto::view::Variant> { fn to_state_proto(&self, cx: &WindowContext) -> Option<proto::view::Variant> {
let channel_buffer = self.channel_buffer.read(cx); let channel_buffer = self.channel_buffer.read(cx);
if !channel_buffer.is_connected() { if !channel_buffer.is_connected() {
return None; return None;
@ -350,12 +346,12 @@ impl FollowableItem for ChannelView {
} }
fn from_state_proto( fn from_state_proto(
pane: ViewHandle<workspace::Pane>, pane: View<workspace::Pane>,
workspace: ViewHandle<workspace::Workspace>, workspace: View<workspace::Workspace>,
remote_id: workspace::ViewId, remote_id: workspace::ViewId,
state: &mut Option<proto::view::Variant>, state: &mut Option<proto::view::Variant>,
cx: &mut AppContext, cx: &mut WindowContext,
) -> Option<gpui::Task<anyhow::Result<ViewHandle<Self>>>> { ) -> Option<gpui::Task<anyhow::Result<View<Self>>>> {
let Some(proto::view::Variant::ChannelView(_)) = state else { let Some(proto::view::Variant::ChannelView(_)) = state else {
return None; return None;
}; };
@ -368,30 +364,28 @@ impl FollowableItem for ChannelView {
Some(cx.spawn(|mut cx| async move { Some(cx.spawn(|mut cx| async move {
let this = open.await?; let this = open.await?;
let task = this let task = this.update(&mut cx, |this, cx| {
.update(&mut cx, |this, cx| { this.remote_id = Some(remote_id);
this.remote_id = Some(remote_id);
if let Some(state) = state.editor { if let Some(state) = state.editor {
Some(this.editor.update(cx, |editor, cx| { Some(this.editor.update(cx, |editor, cx| {
editor.apply_update_proto( editor.apply_update_proto(
&this.project, &this.project,
proto::update_view::Variant::Editor(proto::update_view::Editor { proto::update_view::Variant::Editor(proto::update_view::Editor {
selections: state.selections, selections: state.selections,
pending_selection: state.pending_selection, pending_selection: state.pending_selection,
scroll_top_anchor: state.scroll_top_anchor, scroll_top_anchor: state.scroll_top_anchor,
scroll_x: state.scroll_x, scroll_x: state.scroll_x,
scroll_y: state.scroll_y, scroll_y: state.scroll_y,
..Default::default() ..Default::default()
}), }),
cx, cx,
) )
})) }))
} else { } else {
None None
} }
}) })?;
.ok_or_else(|| anyhow!("window was closed"))?;
if let Some(task) = task { if let Some(task) = task {
task.await?; task.await?;
@ -403,9 +397,9 @@ impl FollowableItem for ChannelView {
fn add_event_to_update_proto( fn add_event_to_update_proto(
&self, &self,
event: &Self::Event, event: &EditorEvent,
update: &mut Option<proto::update_view::Variant>, update: &mut Option<proto::update_view::Variant>,
cx: &AppContext, cx: &WindowContext,
) -> bool { ) -> bool {
self.editor self.editor
.read(cx) .read(cx)
@ -414,7 +408,7 @@ impl FollowableItem for ChannelView {
fn apply_update_proto( fn apply_update_proto(
&mut self, &mut self,
project: &ModelHandle<Project>, project: &Model<Project>,
message: proto::update_view::Variant, message: proto::update_view::Variant,
cx: &mut ViewContext<Self>, cx: &mut ViewContext<Self>,
) -> gpui::Task<anyhow::Result<()>> { ) -> gpui::Task<anyhow::Result<()>> {
@ -429,16 +423,16 @@ impl FollowableItem for ChannelView {
}) })
} }
fn should_unfollow_on_event(event: &Self::Event, cx: &AppContext) -> bool { fn is_project_item(&self, _cx: &WindowContext) -> bool {
Editor::should_unfollow_on_event(event, cx) false
} }
fn is_project_item(&self, _cx: &AppContext) -> bool { fn to_follow_event(event: &Self::Event) -> Option<workspace::item::FollowEvent> {
false Editor::to_follow_event(event)
} }
} }
struct ChannelBufferCollaborationHub(ModelHandle<ChannelBuffer>); struct ChannelBufferCollaborationHub(Model<ChannelBuffer>);
impl CollaborationHub for ChannelBufferCollaborationHub { impl CollaborationHub for ChannelBufferCollaborationHub {
fn collaborators<'a>(&self, cx: &'a AppContext) -> &'a HashMap<PeerId, Collaborator> { fn collaborators<'a>(&self, cx: &'a AppContext) -> &'a HashMap<PeerId, Collaborator> {

File diff suppressed because it is too large Load diff

View file

@ -3,13 +3,14 @@ use client::UserId;
use collections::HashMap; use collections::HashMap;
use editor::{AnchorRangeExt, Editor}; use editor::{AnchorRangeExt, Editor};
use gpui::{ use gpui::{
elements::ChildView, AnyElement, AsyncAppContext, Element, Entity, ModelHandle, Task, View, AsyncWindowContext, FocusableView, IntoElement, Model, Render, SharedString, Task, View,
ViewContext, ViewHandle, WeakViewHandle, ViewContext, WeakView,
}; };
use language::{language_settings::SoftWrap, Buffer, BufferSnapshot, LanguageRegistry}; use language::{language_settings::SoftWrap, Buffer, BufferSnapshot, LanguageRegistry};
use lazy_static::lazy_static; use lazy_static::lazy_static;
use project::search::SearchQuery; use project::search::SearchQuery;
use std::{sync::Arc, time::Duration}; use std::{sync::Arc, time::Duration};
use workspace::item::ItemHandle;
const MENTIONS_DEBOUNCE_INTERVAL: Duration = Duration::from_millis(50); const MENTIONS_DEBOUNCE_INTERVAL: Duration = Duration::from_millis(50);
@ -19,8 +20,8 @@ lazy_static! {
} }
pub struct MessageEditor { pub struct MessageEditor {
pub editor: ViewHandle<Editor>, pub editor: View<Editor>,
channel_store: ModelHandle<ChannelStore>, channel_store: Model<ChannelStore>,
users: HashMap<String, UserId>, users: HashMap<String, UserId>,
mentions: Vec<UserId>, mentions: Vec<UserId>,
mentions_task: Option<Task<()>>, mentions_task: Option<Task<()>>,
@ -30,8 +31,8 @@ pub struct MessageEditor {
impl MessageEditor { impl MessageEditor {
pub fn new( pub fn new(
language_registry: Arc<LanguageRegistry>, language_registry: Arc<LanguageRegistry>,
channel_store: ModelHandle<ChannelStore>, channel_store: Model<ChannelStore>,
editor: ViewHandle<Editor>, editor: View<Editor>,
cx: &mut ViewContext<Self>, cx: &mut ViewContext<Self>,
) -> Self { ) -> Self {
editor.update(cx, |editor, cx| { editor.update(cx, |editor, cx| {
@ -48,15 +49,13 @@ impl MessageEditor {
cx.subscribe(&buffer, Self::on_buffer_event).detach(); cx.subscribe(&buffer, Self::on_buffer_event).detach();
let markdown = language_registry.language_for_name("Markdown"); let markdown = language_registry.language_for_name("Markdown");
cx.app_context() cx.spawn(|_, mut cx| async move {
.spawn(|mut cx| async move { let markdown = markdown.await?;
let markdown = markdown.await?; buffer.update(&mut cx, |buffer, cx| {
buffer.update(&mut cx, |buffer, cx| { buffer.set_language(Some(markdown), cx)
buffer.set_language(Some(markdown), cx)
});
anyhow::Ok(())
}) })
.detach_and_log_err(cx); })
.detach_and_log_err(cx);
Self { Self {
editor, editor,
@ -71,7 +70,7 @@ impl MessageEditor {
pub fn set_channel( pub fn set_channel(
&mut self, &mut self,
channel_id: u64, channel_id: u64,
channel_name: Option<String>, channel_name: Option<SharedString>,
cx: &mut ViewContext<Self>, cx: &mut ViewContext<Self>,
) { ) {
self.editor.update(cx, |editor, cx| { self.editor.update(cx, |editor, cx| {
@ -132,26 +131,28 @@ impl MessageEditor {
fn on_buffer_event( fn on_buffer_event(
&mut self, &mut self,
buffer: ModelHandle<Buffer>, buffer: Model<Buffer>,
event: &language::Event, event: &language::Event,
cx: &mut ViewContext<Self>, cx: &mut ViewContext<Self>,
) { ) {
if let language::Event::Reparsed | language::Event::Edited = event { if let language::Event::Reparsed | language::Event::Edited = event {
let buffer = buffer.read(cx).snapshot(); let buffer = buffer.read(cx).snapshot();
self.mentions_task = Some(cx.spawn(|this, cx| async move { self.mentions_task = Some(cx.spawn(|this, cx| async move {
cx.background().timer(MENTIONS_DEBOUNCE_INTERVAL).await; cx.background_executor()
.timer(MENTIONS_DEBOUNCE_INTERVAL)
.await;
Self::find_mentions(this, buffer, cx).await; Self::find_mentions(this, buffer, cx).await;
})); }));
} }
} }
async fn find_mentions( async fn find_mentions(
this: WeakViewHandle<MessageEditor>, this: WeakView<MessageEditor>,
buffer: BufferSnapshot, buffer: BufferSnapshot,
mut cx: AsyncAppContext, mut cx: AsyncWindowContext,
) { ) {
let (buffer, ranges) = cx let (buffer, ranges) = cx
.background() .background_executor()
.spawn(async move { .spawn(async move {
let ranges = MENTIONS_SEARCH.search(&buffer, None).await; let ranges = MENTIONS_SEARCH.search(&buffer, None).await;
(buffer, ranges) (buffer, ranges)
@ -180,11 +181,7 @@ impl MessageEditor {
} }
editor.clear_highlights::<Self>(cx); editor.clear_highlights::<Self>(cx);
editor.highlight_text::<Self>( editor.highlight_text::<Self>(anchor_ranges, gpui::red().into(), cx)
anchor_ranges,
theme::current(cx).chat_panel.rich_text.mention_highlight,
cx,
)
}); });
this.mentions = mentioned_user_ids; this.mentions = mentioned_user_ids;
@ -192,21 +189,15 @@ impl MessageEditor {
}) })
.ok(); .ok();
} }
}
impl Entity for MessageEditor { pub(crate) fn focus_handle(&self, cx: &gpui::AppContext) -> gpui::FocusHandle {
type Event = (); self.editor.read(cx).focus_handle(cx)
}
impl View for MessageEditor {
fn render(&mut self, cx: &mut ViewContext<'_, '_, Self>) -> AnyElement<Self> {
ChildView::new(&self.editor, cx).into_any()
} }
}
fn focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) { impl Render for MessageEditor {
if cx.is_self_focused() { fn render(&mut self, _cx: &mut ViewContext<Self>) -> impl IntoElement {
cx.focus(&self.editor); self.editor.to_any()
}
} }
} }
@ -214,7 +205,7 @@ impl View for MessageEditor {
mod tests { mod tests {
use super::*; use super::*;
use client::{Client, User, UserStore}; use client::{Client, User, UserStore};
use gpui::{TestAppContext, WindowHandle}; use gpui::{Context as _, TestAppContext, VisualContext as _};
use language::{Language, LanguageConfig}; use language::{Language, LanguageConfig};
use rpc::proto; use rpc::proto;
use settings::SettingsStore; use settings::SettingsStore;
@ -222,8 +213,17 @@ mod tests {
#[gpui::test] #[gpui::test]
async fn test_message_editor(cx: &mut TestAppContext) { async fn test_message_editor(cx: &mut TestAppContext) {
let editor = init_test(cx); let language_registry = init_test(cx);
let editor = editor.root(cx);
let (editor, cx) = cx.add_window_view(|cx| {
MessageEditor::new(
language_registry,
ChannelStore::global(cx),
cx.new_view(|cx| Editor::auto_height(4, cx)),
cx,
)
});
cx.executor().run_until_parked();
editor.update(cx, |editor, cx| { editor.update(cx, |editor, cx| {
editor.set_members( editor.set_members(
@ -232,7 +232,7 @@ mod tests {
user: Arc::new(User { user: Arc::new(User {
github_login: "a-b".into(), github_login: "a-b".into(),
id: 101, id: 101,
avatar: None, avatar_uri: "avatar_a-b".into(),
}), }),
kind: proto::channel_member::Kind::Member, kind: proto::channel_member::Kind::Member,
role: proto::ChannelRole::Member, role: proto::ChannelRole::Member,
@ -241,7 +241,7 @@ mod tests {
user: Arc::new(User { user: Arc::new(User {
github_login: "C_D".into(), github_login: "C_D".into(),
id: 102, id: 102,
avatar: None, avatar_uri: "avatar_C_D".into(),
}), }),
kind: proto::channel_member::Kind::Member, kind: proto::channel_member::Kind::Member,
role: proto::ChannelRole::Member, role: proto::ChannelRole::Member,
@ -255,7 +255,7 @@ mod tests {
}); });
}); });
cx.foreground().advance_clock(MENTIONS_DEBOUNCE_INTERVAL); cx.executor().advance_clock(MENTIONS_DEBOUNCE_INTERVAL);
editor.update(cx, |editor, cx| { editor.update(cx, |editor, cx| {
let (text, ranges) = marked_text_ranges("Hello, «@a-b»! Have you met «@C_D»?", false); let (text, ranges) = marked_text_ranges("Hello, «@a-b»! Have you met «@C_D»?", false);
@ -269,15 +269,14 @@ mod tests {
}); });
} }
fn init_test(cx: &mut TestAppContext) -> WindowHandle<MessageEditor> { fn init_test(cx: &mut TestAppContext) -> Arc<LanguageRegistry> {
cx.foreground().forbid_parking();
cx.update(|cx| { cx.update(|cx| {
let http = FakeHttpClient::with_404_response(); let http = FakeHttpClient::with_404_response();
let client = Client::new(http.clone(), cx); let client = Client::new(http.clone(), cx);
let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http, cx)); let user_store = cx.new_model(|cx| UserStore::new(client.clone(), cx));
cx.set_global(SettingsStore::test(cx)); let settings = SettingsStore::test(cx);
theme::init((), cx); cx.set_global(settings);
theme::init(theme::LoadThemes::JustBase, cx);
language::init(cx); language::init(cx);
editor::init(cx); editor::init(cx);
client::init(&client, cx); client::init(&client, cx);
@ -292,16 +291,6 @@ mod tests {
}, },
Some(tree_sitter_markdown::language()), Some(tree_sitter_markdown::language()),
))); )));
language_registry
let editor = cx.add_window(|cx| {
MessageEditor::new(
language_registry,
ChannelStore::global(cx),
cx.add_view(|cx| Editor::auto_height(4, None, cx)),
cx,
)
});
cx.foreground().run_until_parked();
editor
} }
} }

File diff suppressed because it is too large Load diff

View file

@ -3,19 +3,17 @@ use client::{
proto::{self, ChannelRole, ChannelVisibility}, proto::{self, ChannelRole, ChannelVisibility},
User, UserId, UserStore, User, UserId, UserStore,
}; };
use context_menu::{ContextMenu, ContextMenuItem};
use fuzzy::{match_strings, StringMatchCandidate}; use fuzzy::{match_strings, StringMatchCandidate};
use gpui::{ use gpui::{
actions, actions, div, overlay, AppContext, ClipboardItem, DismissEvent, EventEmitter, FocusableView,
elements::*, Model, ParentElement, Render, Styled, Subscription, Task, View, ViewContext, VisualContext,
platform::{CursorStyle, MouseButton}, WeakView,
AppContext, ClipboardItem, Entity, ModelHandle, MouseState, Task, View, ViewContext,
ViewHandle,
}; };
use picker::{Picker, PickerDelegate, PickerEvent}; use picker::{Picker, PickerDelegate};
use std::sync::Arc; use std::sync::Arc;
use ui::{prelude::*, Avatar, Checkbox, ContextMenu, ListItem, ListItemSpacing};
use util::TryFutureExt; use util::TryFutureExt;
use workspace::Modal; use workspace::ModalView;
actions!( actions!(
channel_modal, channel_modal,
@ -27,34 +25,27 @@ actions!(
] ]
); );
pub fn init(cx: &mut AppContext) {
Picker::<ChannelModalDelegate>::init(cx);
cx.add_action(ChannelModal::toggle_mode);
cx.add_action(ChannelModal::toggle_member_admin);
cx.add_action(ChannelModal::remove_member);
cx.add_action(ChannelModal::dismiss);
}
pub struct ChannelModal { pub struct ChannelModal {
picker: ViewHandle<Picker<ChannelModalDelegate>>, picker: View<Picker<ChannelModalDelegate>>,
channel_store: ModelHandle<ChannelStore>, channel_store: Model<ChannelStore>,
channel_id: ChannelId, channel_id: ChannelId,
has_focus: bool,
} }
impl ChannelModal { impl ChannelModal {
pub fn new( pub fn new(
user_store: ModelHandle<UserStore>, user_store: Model<UserStore>,
channel_store: ModelHandle<ChannelStore>, channel_store: Model<ChannelStore>,
channel_id: ChannelId, channel_id: ChannelId,
mode: Mode, mode: Mode,
members: Vec<ChannelMembership>, members: Vec<ChannelMembership>,
cx: &mut ViewContext<Self>, cx: &mut ViewContext<Self>,
) -> Self { ) -> Self {
cx.observe(&channel_store, |_, _, cx| cx.notify()).detach(); cx.observe(&channel_store, |_, _, cx| cx.notify()).detach();
let picker = cx.add_view(|cx| { let channel_modal = cx.view().downgrade();
let picker = cx.new_view(|cx| {
Picker::new( Picker::new(
ChannelModalDelegate { ChannelModalDelegate {
channel_modal,
matching_users: Vec::new(), matching_users: Vec::new(),
matching_member_indices: Vec::new(), matching_member_indices: Vec::new(),
selected_index: 0, selected_index: 0,
@ -62,33 +53,24 @@ impl ChannelModal {
channel_store: channel_store.clone(), channel_store: channel_store.clone(),
channel_id, channel_id,
match_candidates: Vec::new(), match_candidates: Vec::new(),
context_menu: None,
members, members,
mode, mode,
context_menu: cx.add_view(|cx| {
let mut menu = ContextMenu::new(cx.view_id(), cx);
menu.set_position_mode(OverlayPositionMode::Local);
menu
}),
}, },
cx, cx,
) )
.with_theme(|theme| theme.collab_panel.tabbed_modal.picker.clone()) .modal(false)
}); });
cx.subscribe(&picker, |_, _, e, cx| cx.emit(*e)).detach();
let has_focus = picker.read(cx).has_focus();
Self { Self {
picker, picker,
channel_store, channel_store,
channel_id, channel_id,
has_focus,
} }
} }
fn toggle_mode(&mut self, _: &ToggleMode, cx: &mut ViewContext<Self>) { fn toggle_mode(&mut self, _: &ToggleMode, cx: &mut ViewContext<Self>) {
let mode = match self.picker.read(cx).delegate().mode { let mode = match self.picker.read(cx).delegate.mode {
Mode::ManageMembers => Mode::InviteMembers, Mode::ManageMembers => Mode::InviteMembers,
Mode::InviteMembers => Mode::ManageMembers, Mode::InviteMembers => Mode::ManageMembers,
}; };
@ -103,20 +85,20 @@ impl ChannelModal {
let mut members = channel_store let mut members = channel_store
.update(&mut cx, |channel_store, cx| { .update(&mut cx, |channel_store, cx| {
channel_store.get_channel_member_details(channel_id, cx) channel_store.get_channel_member_details(channel_id, cx)
}) })?
.await?; .await?;
members.sort_by(|a, b| a.sort_key().cmp(&b.sort_key())); members.sort_by(|a, b| a.sort_key().cmp(&b.sort_key()));
this.update(&mut cx, |this, cx| { this.update(&mut cx, |this, cx| {
this.picker this.picker
.update(cx, |picker, _| picker.delegate_mut().members = members); .update(cx, |picker, _| picker.delegate.members = members);
})?; })?;
} }
this.update(&mut cx, |this, cx| { this.update(&mut cx, |this, cx| {
this.picker.update(cx, |picker, cx| { this.picker.update(cx, |picker, cx| {
let delegate = picker.delegate_mut(); let delegate = &mut picker.delegate;
delegate.mode = mode; delegate.mode = mode;
delegate.selected_index = 0; delegate.selected_index = 0;
picker.set_query("", cx); picker.set_query("", cx);
@ -129,204 +111,118 @@ impl ChannelModal {
.detach(); .detach();
} }
fn toggle_member_admin(&mut self, _: &ToggleMemberAdmin, cx: &mut ViewContext<Self>) { fn set_channel_visiblity(&mut self, selection: &Selection, cx: &mut ViewContext<Self>) {
self.picker.update(cx, |picker, cx| { self.channel_store.update(cx, |channel_store, cx| {
picker.delegate_mut().toggle_selected_member_admin(cx); channel_store
}) .set_channel_visibility(
} self.channel_id,
match selection {
fn remove_member(&mut self, _: &RemoveMember, cx: &mut ViewContext<Self>) { Selection::Unselected => ChannelVisibility::Members,
self.picker.update(cx, |picker, cx| { Selection::Selected => ChannelVisibility::Public,
picker.delegate_mut().remove_selected_member(cx); Selection::Indeterminate => return,
},
cx,
)
.detach_and_log_err(cx)
}); });
} }
fn dismiss(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) { fn dismiss(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
cx.emit(PickerEvent::Dismiss); cx.emit(DismissEvent);
} }
} }
impl Entity for ChannelModal { impl EventEmitter<DismissEvent> for ChannelModal {}
type Event = PickerEvent; impl ModalView for ChannelModal {}
impl FocusableView for ChannelModal {
fn focus_handle(&self, cx: &AppContext) -> gpui::FocusHandle {
self.picker.focus_handle(cx)
}
} }
impl View for ChannelModal { impl Render for ChannelModal {
fn ui_name() -> &'static str { fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
"ChannelModal" let channel_store = self.channel_store.read(cx);
} let Some(channel) = channel_store.channel_for_id(self.channel_id) else {
return div();
fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
let theme = &theme::current(cx).collab_panel.tabbed_modal;
let mode = self.picker.read(cx).delegate().mode;
let Some(channel) = self.channel_store.read(cx).channel_for_id(self.channel_id) else {
return Empty::new().into_any();
}; };
let channel_name = channel.name.clone();
let channel_id = channel.id;
let visibility = channel.visibility;
let mode = self.picker.read(cx).delegate.mode;
enum InviteMembers {} v_stack()
enum ManageMembers {} .key_context("ChannelModal")
.on_action(cx.listener(Self::toggle_mode))
fn render_mode_button<T: 'static>( .on_action(cx.listener(Self::dismiss))
mode: Mode, .elevation_3(cx)
text: &'static str, .w(rems(34.))
current_mode: Mode, .child(
theme: &theme::TabbedModal, v_stack()
cx: &mut ViewContext<ChannelModal>, .px_2()
) -> AnyElement<ChannelModal> { .py_1()
let active = mode == current_mode; .rounded_t(px(8.))
MouseEventHandler::new::<T, _>(0, cx, move |state, _| { .bg(cx.theme().colors().element_background)
let contained_text = theme.tab_button.style_for(active, state); .child(IconElement::new(Icon::Hash).size(IconSize::Medium))
Label::new(text, contained_text.text.clone()) .child(Label::new(channel_name))
.contained() .child(
.with_style(contained_text.container.clone()) h_stack()
}) .w_full()
.on_click(MouseButton::Left, move |_, this, cx| { .justify_between()
if !active { .child(
this.set_mode(mode, cx); h_stack()
} .gap_2()
}) .child(
.with_cursor_style(CursorStyle::PointingHand) Checkbox::new(
.into_any() "is-public",
} if visibility == ChannelVisibility::Public {
ui::Selection::Selected
fn render_visibility( } else {
channel_id: ChannelId, ui::Selection::Unselected
visibility: ChannelVisibility, },
theme: &theme::TabbedModal, )
cx: &mut ViewContext<ChannelModal>, .on_click(cx.listener(Self::set_channel_visiblity)),
) -> AnyElement<ChannelModal> {
enum TogglePublic {}
if visibility == ChannelVisibility::Members {
return Flex::row()
.with_child(
MouseEventHandler::new::<TogglePublic, _>(0, cx, move |state, _| {
let style = theme.visibility_toggle.style_for(state);
Label::new(format!("{}", "Public access: OFF"), style.text.clone())
.contained()
.with_style(style.container.clone())
})
.on_click(MouseButton::Left, move |_, this, cx| {
this.channel_store
.update(cx, |channel_store, cx| {
channel_store.set_channel_visibility(
channel_id,
ChannelVisibility::Public,
cx,
) )
}) .child(Label::new("Public")),
.detach_and_log_err(cx); )
}) .children(if visibility == ChannelVisibility::Public {
.with_cursor_style(CursorStyle::PointingHand), Some(Button::new("copy-link", "Copy Link").on_click(cx.listener(
move |this, _, cx| {
if let Some(channel) =
this.channel_store.read(cx).channel_for_id(channel_id)
{
let item = ClipboardItem::new(channel.link());
cx.write_to_clipboard(item);
}
},
)))
} else {
None
}),
) )
.into_any(); .child(
} div()
.w_full()
Flex::row() .flex()
.with_child( .flex_row()
MouseEventHandler::new::<TogglePublic, _>(0, cx, move |state, _| { .child(
let style = theme.visibility_toggle.style_for(state); Button::new("manage-members", "Manage Members")
Label::new(format!("{}", "Public access: ON"), style.text.clone()) .selected(mode == Mode::ManageMembers)
.contained() .on_click(cx.listener(|this, _, cx| {
.with_style(style.container.clone()) this.set_mode(Mode::ManageMembers, cx);
}) })),
.on_click(MouseButton::Left, move |_, this, cx| { )
this.channel_store .child(
.update(cx, |channel_store, cx| { Button::new("invite-members", "Invite Members")
channel_store.set_channel_visibility( .selected(mode == Mode::InviteMembers)
channel_id, .on_click(cx.listener(|this, _, cx| {
ChannelVisibility::Members, this.set_mode(Mode::InviteMembers, cx);
cx, })),
) ),
}) ),
.detach_and_log_err(cx);
})
.with_cursor_style(CursorStyle::PointingHand),
)
.with_spacing(14.0)
.with_child(
MouseEventHandler::new::<TogglePublic, _>(1, cx, move |state, _| {
let style = theme.channel_link.style_for(state);
Label::new(format!("{}", "copy link"), style.text.clone())
.contained()
.with_style(style.container.clone())
})
.on_click(MouseButton::Left, move |_, this, cx| {
if let Some(channel) =
this.channel_store.read(cx).channel_for_id(channel_id)
{
let item = ClipboardItem::new(channel.link());
cx.write_to_clipboard(item);
}
})
.with_cursor_style(CursorStyle::PointingHand),
)
.into_any()
}
Flex::column()
.with_child(
Flex::column()
.with_child(
Label::new(format!("#{}", channel.name), theme.title.text.clone())
.contained()
.with_style(theme.title.container.clone()),
)
.with_child(render_visibility(channel.id, channel.visibility, theme, cx))
.with_child(Flex::row().with_children([
render_mode_button::<InviteMembers>(
Mode::InviteMembers,
"Invite members",
mode,
theme,
cx,
),
render_mode_button::<ManageMembers>(
Mode::ManageMembers,
"Manage members",
mode,
theme,
cx,
),
]))
.expanded()
.contained()
.with_style(theme.header),
) )
.with_child( .child(self.picker.clone())
ChildView::new(&self.picker, cx)
.contained()
.with_style(theme.body),
)
.constrained()
.with_max_height(theme.max_height)
.with_max_width(theme.max_width)
.contained()
.with_style(theme.modal)
.into_any()
}
fn focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
self.has_focus = true;
if cx.is_self_focused() {
cx.focus(&self.picker)
}
}
fn focus_out(&mut self, _: gpui::AnyViewHandle, _: &mut ViewContext<Self>) {
self.has_focus = false;
}
}
impl Modal for ChannelModal {
fn has_focus(&self) -> bool {
self.has_focus
}
fn dismiss_on_event(event: &Self::Event) -> bool {
match event {
PickerEvent::Dismiss => true,
}
} }
} }
@ -337,19 +233,22 @@ pub enum Mode {
} }
pub struct ChannelModalDelegate { pub struct ChannelModalDelegate {
channel_modal: WeakView<ChannelModal>,
matching_users: Vec<Arc<User>>, matching_users: Vec<Arc<User>>,
matching_member_indices: Vec<usize>, matching_member_indices: Vec<usize>,
user_store: ModelHandle<UserStore>, user_store: Model<UserStore>,
channel_store: ModelHandle<ChannelStore>, channel_store: Model<ChannelStore>,
channel_id: ChannelId, channel_id: ChannelId,
selected_index: usize, selected_index: usize,
mode: Mode, mode: Mode,
match_candidates: Vec<StringMatchCandidate>, match_candidates: Vec<StringMatchCandidate>,
members: Vec<ChannelMembership>, members: Vec<ChannelMembership>,
context_menu: ViewHandle<ContextMenu>, context_menu: Option<(View<ContextMenu>, Subscription)>,
} }
impl PickerDelegate for ChannelModalDelegate { impl PickerDelegate for ChannelModalDelegate {
type ListItem = ListItem;
fn placeholder_text(&self) -> Arc<str> { fn placeholder_text(&self) -> Arc<str> {
"Search collaborator by username...".into() "Search collaborator by username...".into()
} }
@ -382,19 +281,19 @@ impl PickerDelegate for ChannelModalDelegate {
} }
})); }));
let matches = cx.background().block(match_strings( let matches = cx.background_executor().block(match_strings(
&self.match_candidates, &self.match_candidates,
&query, &query,
true, true,
usize::MAX, usize::MAX,
&Default::default(), &Default::default(),
cx.background().clone(), cx.background_executor().clone(),
)); ));
cx.spawn(|picker, mut cx| async move { cx.spawn(|picker, mut cx| async move {
picker picker
.update(&mut cx, |picker, cx| { .update(&mut cx, |picker, cx| {
let delegate = picker.delegate_mut(); let delegate = &mut picker.delegate;
delegate.matching_member_indices.clear(); delegate.matching_member_indices.clear();
delegate delegate
.matching_member_indices .matching_member_indices
@ -412,8 +311,7 @@ impl PickerDelegate for ChannelModalDelegate {
async { async {
let users = search_users.await?; let users = search_users.await?;
picker.update(&mut cx, |picker, cx| { picker.update(&mut cx, |picker, cx| {
let delegate = picker.delegate_mut(); picker.delegate.matching_users = users;
delegate.matching_users = users;
cx.notify(); cx.notify();
})?; })?;
anyhow::Ok(()) anyhow::Ok(())
@ -429,11 +327,11 @@ impl PickerDelegate for ChannelModalDelegate {
if let Some((selected_user, role)) = self.user_at_index(self.selected_index) { if let Some((selected_user, role)) = self.user_at_index(self.selected_index) {
match self.mode { match self.mode {
Mode::ManageMembers => { Mode::ManageMembers => {
self.show_context_menu(role.unwrap_or(ChannelRole::Member), cx) self.show_context_menu(selected_user, role.unwrap_or(ChannelRole::Member), cx)
} }
Mode::InviteMembers => match self.member_status(selected_user.id, cx) { Mode::InviteMembers => match self.member_status(selected_user.id, cx) {
Some(proto::channel_member::Kind::Invitee) => { Some(proto::channel_member::Kind::Invitee) => {
self.remove_selected_member(cx); self.remove_member(selected_user.id, cx);
} }
Some(proto::channel_member::Kind::AncestorMember) | None => { Some(proto::channel_member::Kind::AncestorMember) | None => {
self.invite_member(selected_user, cx) self.invite_member(selected_user, cx)
@ -445,138 +343,70 @@ impl PickerDelegate for ChannelModalDelegate {
} }
fn dismissed(&mut self, cx: &mut ViewContext<Picker<Self>>) { fn dismissed(&mut self, cx: &mut ViewContext<Picker<Self>>) {
cx.emit(PickerEvent::Dismiss); if self.context_menu.is_none() {
self.channel_modal
.update(cx, |_, cx| {
cx.emit(DismissEvent);
})
.ok();
}
} }
fn render_match( fn render_match(
&self, &self,
ix: usize, ix: usize,
mouse_state: &mut MouseState,
selected: bool, selected: bool,
cx: &gpui::AppContext, cx: &mut ViewContext<Picker<Self>>,
) -> AnyElement<Picker<Self>> { ) -> Option<Self::ListItem> {
let full_theme = &theme::current(cx); let (user, role) = self.user_at_index(ix)?;
let theme = &full_theme.collab_panel.channel_modal;
let tabbed_modal = &full_theme.collab_panel.tabbed_modal;
let (user, role) = self.user_at_index(ix).unwrap();
let request_status = self.member_status(user.id, cx); let request_status = self.member_status(user.id, cx);
let style = tabbed_modal Some(
.picker ListItem::new(ix)
.item .inset(true)
.in_state(selected) .spacing(ListItemSpacing::Sparse)
.style_for(mouse_state); .selected(selected)
.start_slot(Avatar::new(user.avatar_uri.clone()))
let in_manage = matches!(self.mode, Mode::ManageMembers); .child(Label::new(user.github_login.clone()))
.end_slot(h_stack().gap_2().map(|slot| {
let mut result = Flex::row() match self.mode {
.with_children(user.avatar.clone().map(|avatar| { Mode::ManageMembers => slot
Image::from_data(avatar) .children(
.with_style(theme.contact_avatar) if request_status == Some(proto::channel_member::Kind::Invitee) {
.aligned() Some(Label::new("Invited"))
.left() } else {
})) None
.with_child( },
Label::new(user.github_login.clone(), style.label.clone()) )
.contained() .children(match role {
.with_style(theme.contact_username) Some(ChannelRole::Admin) => Some(Label::new("Admin")),
.aligned() Some(ChannelRole::Guest) => Some(Label::new("Guest")),
.left(), _ => None,
) })
.with_children({ .child(IconButton::new("ellipsis", Icon::Ellipsis))
(in_manage && request_status == Some(proto::channel_member::Kind::Invitee)).then( .children(
|| { if let (Some((menu, _)), true) = (&self.context_menu, selected) {
Label::new("Invited", theme.member_tag.text.clone()) Some(
.contained() overlay()
.with_style(theme.member_tag.container) .anchor(gpui::AnchorCorner::TopLeft)
.aligned() .child(menu.clone()),
.left() )
}, } else {
) None
}) },
.with_children(if in_manage && role == Some(ChannelRole::Admin) { ),
Some( Mode::InviteMembers => match request_status {
Label::new("Admin", theme.member_tag.text.clone()) Some(proto::channel_member::Kind::Invitee) => {
.contained() slot.children(Some(Label::new("Invited")))
.with_style(theme.member_tag.container) }
.aligned() Some(proto::channel_member::Kind::Member) => {
.left(), slot.children(Some(Label::new("Member")))
) }
} else if in_manage && role == Some(ChannelRole::Guest) { _ => slot,
Some( },
Label::new("Guest", theme.member_tag.text.clone()) }
.contained() })),
.with_style(theme.member_tag.container) )
.aligned()
.left(),
)
} else {
None
})
.with_children({
let svg = match self.mode {
Mode::ManageMembers => Some(
Svg::new("icons/ellipsis.svg")
.with_color(theme.member_icon.color)
.constrained()
.with_width(theme.member_icon.icon_width)
.aligned()
.constrained()
.with_width(theme.member_icon.button_width)
.with_height(theme.member_icon.button_width)
.contained()
.with_style(theme.member_icon.container),
),
Mode::InviteMembers => match request_status {
Some(proto::channel_member::Kind::Member) => Some(
Svg::new("icons/check.svg")
.with_color(theme.member_icon.color)
.constrained()
.with_width(theme.member_icon.icon_width)
.aligned()
.constrained()
.with_width(theme.member_icon.button_width)
.with_height(theme.member_icon.button_width)
.contained()
.with_style(theme.member_icon.container),
),
Some(proto::channel_member::Kind::Invitee) => Some(
Svg::new("icons/check.svg")
.with_color(theme.invitee_icon.color)
.constrained()
.with_width(theme.invitee_icon.icon_width)
.aligned()
.constrained()
.with_width(theme.invitee_icon.button_width)
.with_height(theme.invitee_icon.button_width)
.contained()
.with_style(theme.invitee_icon.container),
),
Some(proto::channel_member::Kind::AncestorMember) | None => None,
},
};
svg.map(|svg| svg.aligned().flex_float().into_any())
})
.contained()
.with_style(style.container)
.constrained()
.with_height(tabbed_modal.row_height)
.into_any();
if selected {
result = Stack::new()
.with_child(result)
.with_child(
ChildView::new(&self.context_menu, cx)
.aligned()
.top()
.right(),
)
.into_any();
}
result
} }
} }
@ -610,21 +440,20 @@ impl ChannelModalDelegate {
} }
} }
fn toggle_selected_member_admin(&mut self, cx: &mut ViewContext<Picker<Self>>) -> Option<()> { fn set_user_role(
let (user, role) = self.user_at_index(self.selected_index)?; &mut self,
let new_role = if role == Some(ChannelRole::Admin) { user_id: UserId,
ChannelRole::Member new_role: ChannelRole,
} else { cx: &mut ViewContext<Picker<Self>>,
ChannelRole::Admin ) -> Option<()> {
};
let update = self.channel_store.update(cx, |store, cx| { let update = self.channel_store.update(cx, |store, cx| {
store.set_member_role(self.channel_id, user.id, new_role, cx) store.set_member_role(self.channel_id, user_id, new_role, cx)
}); });
cx.spawn(|picker, mut cx| async move { cx.spawn(|picker, mut cx| async move {
update.await?; update.await?;
picker.update(&mut cx, |picker, cx| { picker.update(&mut cx, |picker, cx| {
let this = picker.delegate_mut(); let this = &mut picker.delegate;
if let Some(member) = this.members.iter_mut().find(|m| m.user.id == user.id) { if let Some(member) = this.members.iter_mut().find(|m| m.user.id == user_id) {
member.role = new_role; member.role = new_role;
} }
cx.focus_self(); cx.focus_self();
@ -635,16 +464,14 @@ impl ChannelModalDelegate {
Some(()) Some(())
} }
fn remove_selected_member(&mut self, cx: &mut ViewContext<Picker<Self>>) -> Option<()> { fn remove_member(&mut self, user_id: UserId, cx: &mut ViewContext<Picker<Self>>) -> Option<()> {
let (user, _) = self.user_at_index(self.selected_index)?;
let user_id = user.id;
let update = self.channel_store.update(cx, |store, cx| { let update = self.channel_store.update(cx, |store, cx| {
store.remove_member(self.channel_id, user_id, cx) store.remove_member(self.channel_id, user_id, cx)
}); });
cx.spawn(|picker, mut cx| async move { cx.spawn(|picker, mut cx| async move {
update.await?; update.await?;
picker.update(&mut cx, |picker, cx| { picker.update(&mut cx, |picker, cx| {
let this = picker.delegate_mut(); let this = &mut picker.delegate;
if let Some(ix) = this.members.iter_mut().position(|m| m.user.id == user_id) { if let Some(ix) = this.members.iter_mut().position(|m| m.user.id == user_id) {
this.members.remove(ix); this.members.remove(ix);
this.matching_member_indices.retain_mut(|member_ix| { this.matching_member_indices.retain_mut(|member_ix| {
@ -661,7 +488,7 @@ impl ChannelModalDelegate {
.selected_index .selected_index
.min(this.matching_member_indices.len().saturating_sub(1)); .min(this.matching_member_indices.len().saturating_sub(1));
cx.focus_self(); picker.focus(cx);
cx.notify(); cx.notify();
}) })
}) })
@ -683,7 +510,7 @@ impl ChannelModalDelegate {
kind: proto::channel_member::Kind::Invitee, kind: proto::channel_member::Kind::Invitee,
role: ChannelRole::Member, role: ChannelRole::Member,
}; };
let members = &mut this.delegate_mut().members; let members = &mut this.delegate.members;
match members.binary_search_by_key(&new_member.sort_key(), |k| k.sort_key()) { match members.binary_search_by_key(&new_member.sort_key(), |k| k.sort_key()) {
Ok(ix) | Err(ix) => members.insert(ix, new_member), Ok(ix) | Err(ix) => members.insert(ix, new_member),
} }
@ -694,24 +521,55 @@ impl ChannelModalDelegate {
.detach_and_log_err(cx); .detach_and_log_err(cx);
} }
fn show_context_menu(&mut self, role: ChannelRole, cx: &mut ViewContext<Picker<Self>>) { fn show_context_menu(
self.context_menu.update(cx, |context_menu, cx| { &mut self,
context_menu.show( user: Arc<User>,
Default::default(), role: ChannelRole,
AnchorCorner::TopRight, cx: &mut ViewContext<Picker<Self>>,
vec![ ) {
ContextMenuItem::action("Remove", RemoveMember), let user_id = user.id;
ContextMenuItem::action( let picker = cx.view().clone();
if role == ChannelRole::Admin { let context_menu = ContextMenu::build(cx, |mut menu, _cx| {
"Make non-admin" menu = menu.entry("Remove Member", None, {
} else { let picker = picker.clone();
"Make admin" move |cx| {
}, picker.update(cx, |picker, cx| {
ToggleMemberAdmin, picker.delegate.remove_member(user_id, cx);
), })
], }
cx, });
)
}) let picker = picker.clone();
match role {
ChannelRole::Admin => {
menu = menu.entry("Revoke Admin", None, move |cx| {
picker.update(cx, |picker, cx| {
picker
.delegate
.set_user_role(user_id, ChannelRole::Member, cx);
})
});
}
ChannelRole::Member => {
menu = menu.entry("Make Admin", None, move |cx| {
picker.update(cx, |picker, cx| {
picker
.delegate
.set_user_role(user_id, ChannelRole::Admin, cx);
})
});
}
_ => {}
};
menu
});
cx.focus_view(&context_menu);
let subscription = cx.subscribe(&context_menu, |picker, _, _: &DismissEvent, cx| {
picker.delegate.context_menu = None;
picker.focus(cx);
cx.notify();
});
self.context_menu = Some((context_menu, subscription));
} }
} }

View file

@ -1,42 +1,30 @@
use client::{ContactRequestStatus, User, UserStore}; use client::{ContactRequestStatus, User, UserStore};
use gpui::{ use gpui::{
elements::*, AppContext, Entity, ModelHandle, MouseState, Task, View, ViewContext, ViewHandle, AppContext, DismissEvent, EventEmitter, FocusHandle, FocusableView, Model, ParentElement as _,
Render, Styled, Task, View, ViewContext, VisualContext, WeakView,
}; };
use picker::{Picker, PickerDelegate, PickerEvent}; use picker::{Picker, PickerDelegate};
use std::sync::Arc; use std::sync::Arc;
use util::TryFutureExt; use theme::ActiveTheme as _;
use workspace::Modal; use ui::{prelude::*, Avatar, ListItem, ListItemSpacing};
use util::{ResultExt as _, TryFutureExt};
pub fn init(cx: &mut AppContext) { use workspace::ModalView;
Picker::<ContactFinderDelegate>::init(cx);
cx.add_action(ContactFinder::dismiss)
}
pub struct ContactFinder { pub struct ContactFinder {
picker: ViewHandle<Picker<ContactFinderDelegate>>, picker: View<Picker<ContactFinderDelegate>>,
has_focus: bool,
} }
impl ContactFinder { impl ContactFinder {
pub fn new(user_store: ModelHandle<UserStore>, cx: &mut ViewContext<Self>) -> Self { pub fn new(user_store: Model<UserStore>, cx: &mut ViewContext<Self>) -> Self {
let picker = cx.add_view(|cx| { let delegate = ContactFinderDelegate {
Picker::new( parent: cx.view().downgrade(),
ContactFinderDelegate { user_store,
user_store, potential_contacts: Arc::from([]),
potential_contacts: Arc::from([]), selected_index: 0,
selected_index: 0, };
}, let picker = cx.new_view(|cx| Picker::new(delegate, cx).modal(false));
cx,
)
.with_theme(|theme| theme.collab_panel.tabbed_modal.picker.clone())
});
cx.subscribe(&picker, |_, _, e, cx| cx.emit(*e)).detach(); Self { picker }
Self {
picker,
has_focus: false,
}
} }
pub fn set_query(&mut self, query: String, cx: &mut ViewContext<Self>) { pub fn set_query(&mut self, query: String, cx: &mut ViewContext<Self>) {
@ -44,101 +32,45 @@ impl ContactFinder {
picker.set_query(query, cx); picker.set_query(query, cx);
}); });
} }
fn dismiss(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
cx.emit(PickerEvent::Dismiss);
}
} }
impl Entity for ContactFinder { impl Render for ContactFinder {
type Event = PickerEvent; fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
} v_stack()
.elevation_3(cx)
impl View for ContactFinder { .child(
fn ui_name() -> &'static str { v_stack()
"ContactFinder" .px_2()
} .py_1()
.bg(cx.theme().colors().element_background)
fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> { // HACK: Prevent the background color from overflowing the parent container.
let full_theme = &theme::current(cx); .rounded_t(px(8.))
let theme = &full_theme.collab_panel.tabbed_modal; .child(Label::new("Contacts"))
.child(h_stack().child(Label::new("Invite new contacts"))),
fn render_mode_button(
text: &'static str,
theme: &theme::TabbedModal,
_cx: &mut ViewContext<ContactFinder>,
) -> AnyElement<ContactFinder> {
let contained_text = &theme.tab_button.active_state().default;
Label::new(text, contained_text.text.clone())
.contained()
.with_style(contained_text.container.clone())
.into_any()
}
Flex::column()
.with_child(
Flex::column()
.with_child(
Label::new("Contacts", theme.title.text.clone())
.contained()
.with_style(theme.title.container.clone()),
)
.with_child(Flex::row().with_children([render_mode_button(
"Invite new contacts",
&theme,
cx,
)]))
.expanded()
.contained()
.with_style(theme.header),
) )
.with_child( .child(self.picker.clone())
ChildView::new(&self.picker, cx) .w(rems(34.))
.contained()
.with_style(theme.body),
)
.constrained()
.with_max_height(theme.max_height)
.with_max_width(theme.max_width)
.contained()
.with_style(theme.modal)
.into_any()
}
fn focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
self.has_focus = true;
if cx.is_self_focused() {
cx.focus(&self.picker)
}
}
fn focus_out(&mut self, _: gpui::AnyViewHandle, _: &mut ViewContext<Self>) {
self.has_focus = false;
}
}
impl Modal for ContactFinder {
fn has_focus(&self) -> bool {
self.has_focus
}
fn dismiss_on_event(event: &Self::Event) -> bool {
match event {
PickerEvent::Dismiss => true,
}
} }
} }
pub struct ContactFinderDelegate { pub struct ContactFinderDelegate {
parent: WeakView<ContactFinder>,
potential_contacts: Arc<[Arc<User>]>, potential_contacts: Arc<[Arc<User>]>,
user_store: ModelHandle<UserStore>, user_store: Model<UserStore>,
selected_index: usize, selected_index: usize,
} }
impl PickerDelegate for ContactFinderDelegate { impl EventEmitter<DismissEvent> for ContactFinder {}
fn placeholder_text(&self) -> Arc<str> { impl ModalView for ContactFinder {}
"Search collaborator by username...".into()
impl FocusableView for ContactFinder {
fn focus_handle(&self, cx: &AppContext) -> FocusHandle {
self.picker.focus_handle(cx)
} }
}
impl PickerDelegate for ContactFinderDelegate {
type ListItem = ListItem;
fn match_count(&self) -> usize { fn match_count(&self) -> usize {
self.potential_contacts.len() self.potential_contacts.len()
@ -152,6 +84,10 @@ impl PickerDelegate for ContactFinderDelegate {
self.selected_index = ix; self.selected_index = ix;
} }
fn placeholder_text(&self) -> Arc<str> {
"Search collaborator by username...".into()
}
fn update_matches(&mut self, query: String, cx: &mut ViewContext<Picker<Self>>) -> Task<()> { fn update_matches(&mut self, query: String, cx: &mut ViewContext<Picker<Self>>) -> Task<()> {
let search_users = self let search_users = self
.user_store .user_store
@ -161,7 +97,7 @@ impl PickerDelegate for ContactFinderDelegate {
async { async {
let potential_contacts = search_users.await?; let potential_contacts = search_users.await?;
picker.update(&mut cx, |picker, cx| { picker.update(&mut cx, |picker, cx| {
picker.delegate_mut().potential_contacts = potential_contacts.into(); picker.delegate.potential_contacts = potential_contacts.into();
cx.notify(); cx.notify();
})?; })?;
anyhow::Ok(()) anyhow::Ok(())
@ -191,19 +127,17 @@ impl PickerDelegate for ContactFinderDelegate {
} }
fn dismissed(&mut self, cx: &mut ViewContext<Picker<Self>>) { fn dismissed(&mut self, cx: &mut ViewContext<Picker<Self>>) {
cx.emit(PickerEvent::Dismiss); self.parent
.update(cx, |_, cx| cx.emit(DismissEvent))
.log_err();
} }
fn render_match( fn render_match(
&self, &self,
ix: usize, ix: usize,
mouse_state: &mut MouseState,
selected: bool, selected: bool,
cx: &gpui::AppContext, cx: &mut ViewContext<Picker<Self>>,
) -> AnyElement<Picker<Self>> { ) -> Option<Self::ListItem> {
let full_theme = &theme::current(cx);
let theme = &full_theme.collab_panel.contact_finder;
let tabbed_modal = &full_theme.collab_panel.tabbed_modal;
let user = &self.potential_contacts[ix]; let user = &self.potential_contacts[ix];
let request_status = self.user_store.read(cx).contact_request_status(user); let request_status = self.user_store.read(cx).contact_request_status(user);
@ -214,48 +148,16 @@ impl PickerDelegate for ContactFinderDelegate {
ContactRequestStatus::RequestSent => Some("icons/x.svg"), ContactRequestStatus::RequestSent => Some("icons/x.svg"),
ContactRequestStatus::RequestAccepted => None, ContactRequestStatus::RequestAccepted => None,
}; };
let button_style = if self.user_store.read(cx).is_contact_request_pending(user) { Some(
&theme.disabled_contact_button ListItem::new(ix)
} else { .inset(true)
&theme.contact_button .spacing(ListItemSpacing::Sparse)
}; .selected(selected)
let style = tabbed_modal .start_slot(Avatar::new(user.avatar_uri.clone()))
.picker .child(Label::new(user.github_login.clone()))
.item .end_slot::<IconElement>(
.in_state(selected) icon_path.map(|icon_path| IconElement::from_path(icon_path)),
.style_for(mouse_state); ),
Flex::row() )
.with_children(user.avatar.clone().map(|avatar| {
Image::from_data(avatar)
.with_style(theme.contact_avatar)
.aligned()
.left()
}))
.with_child(
Label::new(user.github_login.clone(), style.label.clone())
.contained()
.with_style(theme.contact_username)
.aligned()
.left(),
)
.with_children(icon_path.map(|icon_path| {
Svg::new(icon_path)
.with_color(button_style.color)
.constrained()
.with_width(button_style.icon_width)
.aligned()
.contained()
.with_style(button_style.container)
.constrained()
.with_width(button_style.button_width)
.with_height(button_style.button_width)
.aligned()
.flex_float()
}))
.contained()
.with_style(style.container)
.constrained()
.with_height(tabbed_modal.row_height)
.into_any()
} }
} }

File diff suppressed because it is too large Load diff

View file

@ -7,27 +7,22 @@ pub mod notification_panel;
pub mod notifications; pub mod notifications;
mod panel_settings; mod panel_settings;
use std::{rc::Rc, sync::Arc};
use call::{report_call_event_for_room, ActiveCall, Room}; use call::{report_call_event_for_room, ActiveCall, Room};
pub use collab_panel::CollabPanel;
pub use collab_titlebar_item::CollabTitlebarItem;
use feature_flags::{ChannelsAlpha, FeatureFlagAppExt}; use feature_flags::{ChannelsAlpha, FeatureFlagAppExt};
use gpui::{ use gpui::{
actions, actions, point, AppContext, GlobalPixels, Pixels, PlatformDisplay, Size, Task, WindowBounds,
elements::{ContainerStyle, Empty, Image}, WindowKind, WindowOptions,
geometry::{
rect::RectF,
vector::{vec2f, Vector2F},
},
platform::{Screen, WindowBounds, WindowKind, WindowOptions},
AnyElement, AppContext, Element, ImageData, Task,
}; };
use std::{rc::Rc, sync::Arc};
use theme::AvatarStyle;
use util::ResultExt;
use workspace::AppState;
pub use collab_titlebar_item::CollabTitlebarItem;
pub use panel_settings::{ pub use panel_settings::{
ChatPanelSettings, CollaborationPanelSettings, NotificationPanelSettings, ChatPanelSettings, CollaborationPanelSettings, NotificationPanelSettings,
}; };
use settings::Settings;
use util::ResultExt;
use workspace::AppState;
actions!( actions!(
collab, collab,
@ -35,19 +30,21 @@ actions!(
); );
pub fn init(app_state: &Arc<AppState>, cx: &mut AppContext) { pub fn init(app_state: &Arc<AppState>, cx: &mut AppContext) {
settings::register::<CollaborationPanelSettings>(cx); CollaborationPanelSettings::register(cx);
settings::register::<ChatPanelSettings>(cx); ChatPanelSettings::register(cx);
settings::register::<NotificationPanelSettings>(cx); NotificationPanelSettings::register(cx);
vcs_menu::init(cx); vcs_menu::init(cx);
collab_titlebar_item::init(cx); collab_titlebar_item::init(cx);
collab_panel::init(cx); collab_panel::init(cx);
channel_view::init(cx);
chat_panel::init(cx); chat_panel::init(cx);
notification_panel::init(cx);
notifications::init(&app_state, cx); notifications::init(&app_state, cx);
cx.add_global_action(toggle_screen_sharing); // cx.add_global_action(toggle_screen_sharing);
cx.add_global_action(toggle_mute); // cx.add_global_action(toggle_mute);
cx.add_global_action(toggle_deafen); // cx.add_global_action(toggle_deafen);
} }
pub fn toggle_screen_sharing(_: &ToggleScreenSharing, cx: &mut AppContext) { pub fn toggle_screen_sharing(_: &ToggleScreenSharing, cx: &mut AppContext) {
@ -107,58 +104,63 @@ pub fn toggle_deafen(_: &ToggleDeafen, cx: &mut AppContext) {
} }
fn notification_window_options( fn notification_window_options(
screen: Rc<dyn Screen>, screen: Rc<dyn PlatformDisplay>,
window_size: Vector2F, window_size: Size<Pixels>,
) -> WindowOptions<'static> { ) -> WindowOptions {
const NOTIFICATION_PADDING: f32 = 16.; let notification_margin_width = GlobalPixels::from(16.);
let notification_margin_height = GlobalPixels::from(-0.) - GlobalPixels::from(48.);
let screen_bounds = screen.content_bounds(); let screen_bounds = screen.bounds();
let size: Size<GlobalPixels> = window_size.into();
// todo!() use content bounds instead of screen.bounds and get rid of magics in point's 2nd argument.
let bounds = gpui::Bounds::<GlobalPixels> {
origin: screen_bounds.upper_right()
- point(
size.width + notification_margin_width,
notification_margin_height,
),
size: window_size.into(),
};
WindowOptions { WindowOptions {
bounds: WindowBounds::Fixed(RectF::new( bounds: WindowBounds::Fixed(bounds),
screen_bounds.upper_right()
+ vec2f(
-NOTIFICATION_PADDING - window_size.x(),
NOTIFICATION_PADDING,
),
window_size,
)),
titlebar: None, titlebar: None,
center: false, center: false,
focus: false, focus: false,
show: true, show: true,
kind: WindowKind::PopUp, kind: WindowKind::PopUp,
is_movable: false, is_movable: false,
screen: Some(screen), display_id: Some(screen.id()),
} }
} }
fn render_avatar<T: 'static>( // fn render_avatar<T: 'static>(
avatar: Option<Arc<ImageData>>, // avatar: Option<Arc<ImageData>>,
avatar_style: &AvatarStyle, // avatar_style: &AvatarStyle,
container: ContainerStyle, // container: ContainerStyle,
) -> AnyElement<T> { // ) -> AnyElement<T> {
avatar // avatar
.map(|avatar| { // .map(|avatar| {
Image::from_data(avatar) // Image::from_data(avatar)
.with_style(avatar_style.image) // .with_style(avatar_style.image)
.aligned() // .aligned()
.contained() // .contained()
.with_corner_radius(avatar_style.outer_corner_radius) // .with_corner_radius(avatar_style.outer_corner_radius)
.constrained() // .constrained()
.with_width(avatar_style.outer_width) // .with_width(avatar_style.outer_width)
.with_height(avatar_style.outer_width) // .with_height(avatar_style.outer_width)
.into_any() // .into_any()
}) // })
.unwrap_or_else(|| { // .unwrap_or_else(|| {
Empty::new() // Empty::new()
.constrained() // .constrained()
.with_width(avatar_style.outer_width) // .with_width(avatar_style.outer_width)
.into_any() // .into_any()
}) // })
.contained() // .contained()
.with_style(container) // .with_style(container)
.into_any() // .into_any()
} // }
fn is_channels_feature_enabled(cx: &gpui::WindowContext<'_>) -> bool { fn is_channels_feature_enabled(cx: &gpui::WindowContext<'_>) -> bool {
cx.is_staff() || cx.has_flag::<ChannelsAlpha>() cx.is_staff() || cx.has_flag::<ChannelsAlpha>()

View file

@ -1,113 +1,30 @@
use std::ops::Range;
use gpui::{ use gpui::{
geometry::{ div, AnyElement, ElementId, IntoElement, ParentElement, RenderOnce, Styled, WindowContext,
rect::RectF,
vector::{vec2f, Vector2F},
},
json::ToJson,
serde_json::{self, json},
AnyElement, Axis, Element, View, ViewContext,
}; };
use smallvec::SmallVec;
pub(crate) struct FacePile<V: View> { #[derive(Default, IntoElement)]
overlap: f32, pub struct FacePile {
faces: Vec<AnyElement<V>>, pub faces: SmallVec<[AnyElement; 2]>,
} }
impl<V: View> FacePile<V> { impl RenderOnce for FacePile {
pub fn new(overlap: f32) -> Self { fn render(self, _: &mut WindowContext) -> impl IntoElement {
Self { let player_count = self.faces.len();
overlap, let player_list = self.faces.into_iter().enumerate().map(|(ix, player)| {
faces: Vec::new(), let isnt_last = ix < player_count - 1;
}
div()
.z_index((player_count - ix) as u8)
.when(isnt_last, |div| div.neg_mr_1())
.child(player)
});
div().p_1().flex().items_center().children(player_list)
} }
} }
impl<V: View> Element<V> for FacePile<V> { impl ParentElement for FacePile {
type LayoutState = (); fn children_mut(&mut self) -> &mut SmallVec<[AnyElement; 2]> {
type PaintState = (); &mut self.faces
fn layout(
&mut self,
constraint: gpui::SizeConstraint,
view: &mut V,
cx: &mut ViewContext<V>,
) -> (Vector2F, Self::LayoutState) {
debug_assert!(constraint.max_along(Axis::Horizontal) == f32::INFINITY);
let mut width = 0.;
let mut max_height = 0.;
for face in &mut self.faces {
let layout = face.layout(constraint, view, cx);
width += layout.x();
max_height = f32::max(max_height, layout.y());
}
width -= self.overlap * self.faces.len().saturating_sub(1) as f32;
(
Vector2F::new(width, max_height.clamp(1., constraint.max.y())),
(),
)
}
fn paint(
&mut self,
bounds: RectF,
visible_bounds: RectF,
_layout: &mut Self::LayoutState,
view: &mut V,
cx: &mut ViewContext<V>,
) -> Self::PaintState {
let visible_bounds = bounds.intersection(visible_bounds).unwrap_or_default();
let origin_y = bounds.upper_right().y();
let mut origin_x = bounds.upper_right().x();
for face in self.faces.iter_mut().rev() {
let size = face.size();
origin_x -= size.x();
let origin_y = origin_y + (bounds.height() - size.y()) / 2.0;
cx.scene().push_layer(None);
face.paint(vec2f(origin_x, origin_y), visible_bounds, view, cx);
cx.scene().pop_layer();
origin_x += self.overlap;
}
()
}
fn rect_for_text_range(
&self,
_: Range<usize>,
_: RectF,
_: RectF,
_: &Self::LayoutState,
_: &Self::PaintState,
_: &V,
_: &ViewContext<V>,
) -> Option<RectF> {
None
}
fn debug(
&self,
bounds: RectF,
_: &Self::LayoutState,
_: &Self::PaintState,
_: &V,
_: &ViewContext<V>,
) -> serde_json::Value {
json!({
"type": "FacePile",
"bounds": bounds.to_json()
})
}
}
impl<V: View> Extend<AnyElement<V>> for FacePile<V> {
fn extend<T: IntoIterator<Item = AnyElement<V>>>(&mut self, children: T) {
self.faces.extend(children);
} }
} }

View file

@ -1,4 +1,4 @@
use crate::{chat_panel::ChatPanel, render_avatar, NotificationPanelSettings}; use crate::{chat_panel::ChatPanel, NotificationPanelSettings};
use anyhow::Result; use anyhow::Result;
use channel::ChannelStore; use channel::ChannelStore;
use client::{Client, Notification, User, UserStore}; use client::{Client, Notification, User, UserStore};
@ -6,23 +6,23 @@ use collections::HashMap;
use db::kvp::KEY_VALUE_STORE; use db::kvp::KEY_VALUE_STORE;
use futures::StreamExt; use futures::StreamExt;
use gpui::{ use gpui::{
actions, actions, div, img, list, px, serde_json, AnyElement, AppContext, AsyncWindowContext,
elements::*, CursorStyle, DismissEvent, Element, EventEmitter, FocusHandle, FocusableView,
platform::{CursorStyle, MouseButton}, InteractiveElement, IntoElement, ListAlignment, ListScrollEvent, ListState, Model,
serde_json, AnyViewHandle, AppContext, AsyncAppContext, Entity, ModelHandle, Task, View, ParentElement, Render, StatefulInteractiveElement, Styled, Task, View, ViewContext,
ViewContext, ViewHandle, WeakViewHandle, WindowContext, VisualContext, WeakView, WindowContext,
}; };
use notifications::{NotificationEntry, NotificationEvent, NotificationStore}; use notifications::{NotificationEntry, NotificationEvent, NotificationStore};
use project::Fs; use project::Fs;
use rpc::proto; use rpc::proto;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use settings::SettingsStore; use settings::{Settings, SettingsStore};
use std::{sync::Arc, time::Duration}; use std::{sync::Arc, time::Duration};
use theme::{ui, Theme};
use time::{OffsetDateTime, UtcOffset}; use time::{OffsetDateTime, UtcOffset};
use ui::{h_stack, prelude::*, v_stack, Avatar, Button, Icon, IconButton, IconElement, Label};
use util::{ResultExt, TryFutureExt}; use util::{ResultExt, TryFutureExt};
use workspace::{ use workspace::{
dock::{DockPosition, Panel}, dock::{DockPosition, Panel, PanelEvent},
Workspace, Workspace,
}; };
@ -33,25 +33,25 @@ const NOTIFICATION_PANEL_KEY: &'static str = "NotificationPanel";
pub struct NotificationPanel { pub struct NotificationPanel {
client: Arc<Client>, client: Arc<Client>,
user_store: ModelHandle<UserStore>, user_store: Model<UserStore>,
channel_store: ModelHandle<ChannelStore>, channel_store: Model<ChannelStore>,
notification_store: ModelHandle<NotificationStore>, notification_store: Model<NotificationStore>,
fs: Arc<dyn Fs>, fs: Arc<dyn Fs>,
width: Option<f32>, width: Option<Pixels>,
active: bool, active: bool,
notification_list: ListState<Self>, notification_list: ListState,
pending_serialization: Task<Option<()>>, pending_serialization: Task<Option<()>>,
subscriptions: Vec<gpui::Subscription>, subscriptions: Vec<gpui::Subscription>,
workspace: WeakViewHandle<Workspace>, workspace: WeakView<Workspace>,
current_notification_toast: Option<(u64, Task<()>)>, current_notification_toast: Option<(u64, Task<()>)>,
local_timezone: UtcOffset, local_timezone: UtcOffset,
has_focus: bool, focus_handle: FocusHandle,
mark_as_read_tasks: HashMap<u64, Task<Result<()>>>, mark_as_read_tasks: HashMap<u64, Task<Result<()>>>,
} }
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
struct SerializedNotificationPanel { struct SerializedNotificationPanel {
width: Option<f32>, width: Option<Pixels>,
} }
#[derive(Debug)] #[derive(Debug)]
@ -71,16 +71,23 @@ pub struct NotificationPresenter {
actions!(notification_panel, [ToggleFocus]); actions!(notification_panel, [ToggleFocus]);
pub fn init(_cx: &mut AppContext) {} pub fn init(cx: &mut AppContext) {
cx.observe_new_views(|workspace: &mut Workspace, _| {
workspace.register_action(|workspace, _: &ToggleFocus, cx| {
workspace.toggle_panel_focus::<NotificationPanel>(cx);
});
})
.detach();
}
impl NotificationPanel { impl NotificationPanel {
pub fn new(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) -> ViewHandle<Self> { pub fn new(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) -> View<Self> {
let fs = workspace.app_state().fs.clone(); let fs = workspace.app_state().fs.clone();
let client = workspace.app_state().client.clone(); let client = workspace.app_state().client.clone();
let user_store = workspace.app_state().user_store.clone(); let user_store = workspace.app_state().user_store.clone();
let workspace_handle = workspace.weak_handle(); let workspace_handle = workspace.weak_handle();
cx.add_view(|cx| { cx.new_view(|cx: &mut ViewContext<Self>| {
let mut status = client.status(); let mut status = client.status();
cx.spawn(|this, mut cx| async move { cx.spawn(|this, mut cx| async move {
while let Some(_) = status.next().await { while let Some(_) = status.next().await {
@ -96,33 +103,39 @@ impl NotificationPanel {
}) })
.detach(); .detach();
let mut notification_list = let view = cx.view().downgrade();
ListState::<Self>::new(0, Orientation::Top, 1000., move |this, ix, cx| { let notification_list =
this.render_notification(ix, cx) ListState::new(0, ListAlignment::Top, px(1000.), move |ix, cx| {
.unwrap_or_else(|| Empty::new().into_any()) view.upgrade()
.and_then(|view| {
view.update(cx, |this, cx| this.render_notification(ix, cx))
})
.unwrap_or_else(|| div().into_any())
}); });
notification_list.set_scroll_handler(|visible_range, count, this, cx| { notification_list.set_scroll_handler(cx.listener(
if count.saturating_sub(visible_range.end) < LOADING_THRESHOLD { |this, event: &ListScrollEvent, cx| {
if let Some(task) = this if event.count.saturating_sub(event.visible_range.end) < LOADING_THRESHOLD {
.notification_store if let Some(task) = this
.update(cx, |store, cx| store.load_more_notifications(false, cx)) .notification_store
{ .update(cx, |store, cx| store.load_more_notifications(false, cx))
task.detach(); {
task.detach();
}
} }
} },
}); ));
let mut this = Self { let mut this = Self {
fs, fs,
client, client,
user_store, user_store,
local_timezone: cx.platform().local_timezone(), local_timezone: cx.local_timezone(),
channel_store: ChannelStore::global(cx), channel_store: ChannelStore::global(cx),
notification_store: NotificationStore::global(cx), notification_store: NotificationStore::global(cx),
notification_list, notification_list,
pending_serialization: Task::ready(None), pending_serialization: Task::ready(None),
workspace: workspace_handle, workspace: workspace_handle,
has_focus: false, focus_handle: cx.focus_handle(),
current_notification_toast: None, current_notification_toast: None,
subscriptions: Vec::new(), subscriptions: Vec::new(),
active: false, active: false,
@ -134,7 +147,7 @@ impl NotificationPanel {
this.subscriptions.extend([ this.subscriptions.extend([
cx.observe(&this.notification_store, |_, _, cx| cx.notify()), cx.observe(&this.notification_store, |_, _, cx| cx.notify()),
cx.subscribe(&this.notification_store, Self::on_notification_event), cx.subscribe(&this.notification_store, Self::on_notification_event),
cx.observe_global::<SettingsStore, _>(move |this: &mut Self, cx| { cx.observe_global::<SettingsStore>(move |this: &mut Self, cx| {
let new_dock_position = this.position(cx); let new_dock_position = this.position(cx);
if new_dock_position != old_dock_position { if new_dock_position != old_dock_position {
old_dock_position = new_dock_position; old_dock_position = new_dock_position;
@ -148,12 +161,12 @@ impl NotificationPanel {
} }
pub fn load( pub fn load(
workspace: WeakViewHandle<Workspace>, workspace: WeakView<Workspace>,
cx: AsyncAppContext, cx: AsyncWindowContext,
) -> Task<Result<ViewHandle<Self>>> { ) -> Task<Result<View<Self>>> {
cx.spawn(|mut cx| async move { cx.spawn(|mut cx| async move {
let serialized_panel = if let Some(panel) = cx let serialized_panel = if let Some(panel) = cx
.background() .background_executor()
.spawn(async move { KEY_VALUE_STORE.read_kvp(NOTIFICATION_PANEL_KEY) }) .spawn(async move { KEY_VALUE_STORE.read_kvp(NOTIFICATION_PANEL_KEY) })
.await .await
.log_err() .log_err()
@ -179,7 +192,7 @@ impl NotificationPanel {
fn serialize(&mut self, cx: &mut ViewContext<Self>) { fn serialize(&mut self, cx: &mut ViewContext<Self>) {
let width = self.width; let width = self.width;
self.pending_serialization = cx.background().spawn( self.pending_serialization = cx.background_executor().spawn(
async move { async move {
KEY_VALUE_STORE KEY_VALUE_STORE
.write_kvp( .write_kvp(
@ -193,11 +206,7 @@ impl NotificationPanel {
); );
} }
fn render_notification( fn render_notification(&mut self, ix: usize, cx: &mut ViewContext<Self>) -> Option<AnyElement> {
&mut self,
ix: usize,
cx: &mut ViewContext<Self>,
) -> Option<AnyElement<Self>> {
let entry = self.notification_store.read(cx).notification_at(ix)?; let entry = self.notification_store.read(cx).notification_at(ix)?;
let notification_id = entry.id; let notification_id = entry.id;
let now = OffsetDateTime::now_utc(); let now = OffsetDateTime::now_utc();
@ -210,136 +219,99 @@ impl NotificationPanel {
.. ..
} = self.present_notification(entry, cx)?; } = self.present_notification(entry, cx)?;
let theme = theme::current(cx);
let style = &theme.notification_panel;
let response = entry.response; let response = entry.response;
let notification = entry.notification.clone(); let notification = entry.notification.clone();
let message_style = if entry.is_read {
style.read_text.clone()
} else {
style.unread_text.clone()
};
if self.active && !entry.is_read { if self.active && !entry.is_read {
self.did_render_notification(notification_id, &notification, cx); self.did_render_notification(notification_id, &notification, cx);
} }
enum Decline {}
enum Accept {}
Some( Some(
MouseEventHandler::new::<NotificationEntry, _>(ix, cx, |_, cx| { div()
let container = message_style.container; .id(ix)
.flex()
Flex::row() .flex_row()
.with_children(actor.map(|actor| { .size_full()
render_avatar(actor.avatar.clone(), &style.avatar, style.avatar_container) .px_2()
})) .py_1()
.with_child( .gap_2()
Flex::column() .when(can_navigate, |el| {
.with_child(Text::new(text, message_style.text.clone())) el.cursor(CursorStyle::PointingHand).on_click({
.with_child( let notification = notification.clone();
Flex::row() cx.listener(move |this, _, cx| {
.with_child( this.did_click_notification(&notification, cx)
Label::new( })
format_timestamp(timestamp, now, self.local_timezone), })
style.timestamp.text.clone(), })
) .children(actor.map(|actor| {
.contained() img(actor.avatar_uri.clone())
.with_style(style.timestamp.container), .flex_none()
.w_8()
.h_8()
.rounded_full()
}))
.child(
v_stack()
.gap_1()
.size_full()
.overflow_hidden()
.child(Label::new(text.clone()))
.child(
h_stack()
.child(
Label::new(format_timestamp(
timestamp,
now,
self.local_timezone,
))
.color(Color::Muted),
)
.children(if let Some(is_accepted) = response {
Some(div().flex().flex_grow().justify_end().child(Label::new(
if is_accepted {
"You accepted"
} else {
"You declined"
},
)))
} else if needs_response {
Some(
h_stack()
.flex_grow()
.justify_end()
.child(Button::new("decline", "Decline").on_click({
let notification = notification.clone();
let view = cx.view().clone();
move |_, cx| {
view.update(cx, |this, cx| {
this.respond_to_notification(
notification.clone(),
false,
cx,
)
});
}
}))
.child(Button::new("accept", "Accept").on_click({
let notification = notification.clone();
let view = cx.view().clone();
move |_, cx| {
view.update(cx, |this, cx| {
this.respond_to_notification(
notification.clone(),
true,
cx,
)
});
}
})),
) )
.with_children(if let Some(is_accepted) = response { } else {
Some( None
Label::new( }),
if is_accepted { ),
"You accepted" )
} else { .into_any(),
"You declined"
},
style.read_text.text.clone(),
)
.flex_float()
.into_any(),
)
} else if needs_response {
Some(
Flex::row()
.with_children([
MouseEventHandler::new::<Decline, _>(
ix,
cx,
|state, _| {
let button =
style.button.style_for(state);
Label::new(
"Decline",
button.text.clone(),
)
.contained()
.with_style(button.container)
},
)
.with_cursor_style(CursorStyle::PointingHand)
.on_click(MouseButton::Left, {
let notification = notification.clone();
move |_, view, cx| {
view.respond_to_notification(
notification.clone(),
false,
cx,
);
}
}),
MouseEventHandler::new::<Accept, _>(
ix,
cx,
|state, _| {
let button =
style.button.style_for(state);
Label::new(
"Accept",
button.text.clone(),
)
.contained()
.with_style(button.container)
},
)
.with_cursor_style(CursorStyle::PointingHand)
.on_click(MouseButton::Left, {
let notification = notification.clone();
move |_, view, cx| {
view.respond_to_notification(
notification.clone(),
true,
cx,
);
}
}),
])
.flex_float()
.into_any(),
)
} else {
None
}),
)
.flex(1.0, true),
)
.contained()
.with_style(container)
.into_any()
})
.with_cursor_style(if can_navigate {
CursorStyle::PointingHand
} else {
CursorStyle::default()
})
.on_click(MouseButton::Left, {
let notification = notification.clone();
move |_, this, cx| this.did_click_notification(&notification, cx)
})
.into_any(),
) )
} }
@ -432,7 +404,7 @@ impl NotificationPanel {
.or_insert_with(|| { .or_insert_with(|| {
let client = self.client.clone(); let client = self.client.clone();
cx.spawn(|this, mut cx| async move { cx.spawn(|this, mut cx| async move {
cx.background().timer(MARK_AS_READ_DELAY).await; cx.background_executor().timer(MARK_AS_READ_DELAY).await;
client client
.request(proto::MarkNotificationRead { notification_id }) .request(proto::MarkNotificationRead { notification_id })
.await?; .await?;
@ -452,8 +424,8 @@ impl NotificationPanel {
.. ..
} = notification.clone() } = notification.clone()
{ {
if let Some(workspace) = self.workspace.upgrade(cx) { if let Some(workspace) = self.workspace.upgrade() {
cx.app_context().defer(move |cx| { cx.window_context().defer(move |cx| {
workspace.update(cx, |workspace, cx| { workspace.update(cx, |workspace, cx| {
if let Some(panel) = workspace.focus_panel::<ChatPanel>(cx) { if let Some(panel) = workspace.focus_panel::<ChatPanel>(cx) {
panel.update(cx, |panel, cx| { panel.update(cx, |panel, cx| {
@ -468,73 +440,27 @@ impl NotificationPanel {
} }
} }
fn is_showing_notification(&self, notification: &Notification, cx: &AppContext) -> bool { fn is_showing_notification(&self, notification: &Notification, cx: &ViewContext<Self>) -> bool {
if let Notification::ChannelMessageMention { channel_id, .. } = &notification { if let Notification::ChannelMessageMention { channel_id, .. } = &notification {
if let Some(workspace) = self.workspace.upgrade(cx) { if let Some(workspace) = self.workspace.upgrade() {
return workspace return if let Some(panel) = workspace.read(cx).panel::<ChatPanel>(cx) {
.read_with(cx, |workspace, cx| { let panel = panel.read(cx);
if let Some(panel) = workspace.panel::<ChatPanel>(cx) { panel.is_scrolled_to_bottom()
return panel.read_with(cx, |panel, cx| { && panel
panel.is_scrolled_to_bottom() .active_chat()
&& panel.active_chat().map_or(false, |chat| { .map_or(false, |chat| chat.read(cx).channel_id == *channel_id)
chat.read(cx).channel_id == *channel_id } else {
}) false
}); };
}
false
})
.unwrap_or_default();
} }
} }
false false
} }
fn render_sign_in_prompt(
&self,
theme: &Arc<Theme>,
cx: &mut ViewContext<Self>,
) -> AnyElement<Self> {
enum SignInPromptLabel {}
MouseEventHandler::new::<SignInPromptLabel, _>(0, cx, |mouse_state, _| {
Label::new(
"Sign in to view your notifications".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(|_, cx| async move {
client.authenticate_and_connect(true, &cx).log_err().await;
})
.detach();
})
.aligned()
.into_any()
}
fn render_empty_state(
&self,
theme: &Arc<Theme>,
_cx: &mut ViewContext<Self>,
) -> AnyElement<Self> {
Label::new(
"You have no notifications".to_string(),
theme.chat_panel.sign_in_prompt.default.clone(),
)
.aligned()
.into_any()
}
fn on_notification_event( fn on_notification_event(
&mut self, &mut self,
_: ModelHandle<NotificationStore>, _: Model<NotificationStore>,
event: &NotificationEvent, event: &NotificationEvent,
cx: &mut ViewContext<Self>, cx: &mut ViewContext<Self>,
) { ) {
@ -566,7 +492,7 @@ impl NotificationPanel {
self.current_notification_toast = Some(( self.current_notification_toast = Some((
notification_id, notification_id,
cx.spawn(|this, mut cx| async move { cx.spawn(|this, mut cx| async move {
cx.background().timer(TOAST_DURATION).await; cx.background_executor().timer(TOAST_DURATION).await;
this.update(&mut cx, |this, cx| this.remove_toast(notification_id, cx)) this.update(&mut cx, |this, cx| this.remove_toast(notification_id, cx))
.ok(); .ok();
}), }),
@ -576,8 +502,8 @@ impl NotificationPanel {
.update(cx, |workspace, cx| { .update(cx, |workspace, cx| {
workspace.dismiss_notification::<NotificationToast>(0, cx); workspace.dismiss_notification::<NotificationToast>(0, cx);
workspace.show_notification(0, cx, |cx| { workspace.show_notification(0, cx, |cx| {
let workspace = cx.weak_handle(); let workspace = cx.view().downgrade();
cx.add_view(|_| NotificationToast { cx.new_view(|_| NotificationToast {
notification_id, notification_id,
actor, actor,
text, text,
@ -613,62 +539,90 @@ impl NotificationPanel {
} }
} }
impl Entity for NotificationPanel { impl Render for NotificationPanel {
type Event = Event; fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
v_stack()
.size_full()
.child(
h_stack()
.justify_between()
.px_2()
.py_1()
// Match the height of the tab bar so they line up.
.h(rems(ui::Tab::HEIGHT_IN_REMS))
.border_b_1()
.border_color(cx.theme().colors().border)
.child(Label::new("Notifications"))
.child(IconElement::new(Icon::Envelope)),
)
.map(|this| {
if self.client.user_id().is_none() {
this.child(
v_stack()
.gap_2()
.p_4()
.child(
Button::new("sign_in_prompt_button", "Sign in")
.icon_color(Color::Muted)
.icon(Icon::Github)
.icon_position(IconPosition::Start)
.style(ButtonStyle::Filled)
.full_width()
.on_click({
let client = self.client.clone();
move |_, cx| {
let client = client.clone();
cx.spawn(move |cx| async move {
client
.authenticate_and_connect(true, &cx)
.log_err()
.await;
})
.detach()
}
}),
)
.child(
div().flex().w_full().items_center().child(
Label::new("Sign in to view notifications.")
.color(Color::Muted)
.size(LabelSize::Small),
),
),
)
} else if self.notification_list.item_count() == 0 {
this.child(
v_stack().p_4().child(
div().flex().w_full().items_center().child(
Label::new("You have no notifications.")
.color(Color::Muted)
.size(LabelSize::Small),
),
),
)
} else {
this.child(list(self.notification_list.clone()).size_full())
}
})
}
} }
impl View for NotificationPanel { impl FocusableView for NotificationPanel {
fn ui_name() -> &'static str { fn focus_handle(&self, _: &AppContext) -> FocusHandle {
self.focus_handle.clone()
}
}
impl EventEmitter<Event> for NotificationPanel {}
impl EventEmitter<PanelEvent> for NotificationPanel {}
impl Panel for NotificationPanel {
fn persistent_name() -> &'static str {
"NotificationPanel" "NotificationPanel"
} }
fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
let theme = theme::current(cx);
let style = &theme.notification_panel;
let element = if self.client.user_id().is_none() {
self.render_sign_in_prompt(&theme, cx)
} else if self.notification_list.item_count() == 0 {
self.render_empty_state(&theme, cx)
} else {
Flex::column()
.with_child(
Flex::row()
.with_child(Label::new("Notifications", style.title.text.clone()))
.with_child(ui::svg(&style.title_icon).flex_float())
.align_children_center()
.contained()
.with_style(style.title.container)
.constrained()
.with_height(style.title_height),
)
.with_child(
List::new(self.notification_list.clone())
.contained()
.with_style(style.list)
.flex(1., true),
)
.into_any()
};
element
.contained()
.with_style(style.container)
.constrained()
.with_min_width(150.)
.into_any()
}
fn focus_in(&mut self, _: AnyViewHandle, _: &mut ViewContext<Self>) {
self.has_focus = true;
}
fn focus_out(&mut self, _: AnyViewHandle, _: &mut ViewContext<Self>) {
self.has_focus = false;
}
}
impl Panel for NotificationPanel {
fn position(&self, cx: &gpui::WindowContext) -> DockPosition { fn position(&self, cx: &gpui::WindowContext) -> DockPosition {
settings::get::<NotificationPanelSettings>(cx).dock NotificationPanelSettings::get_global(cx).dock
} }
fn position_is_valid(&self, position: DockPosition) -> bool { fn position_is_valid(&self, position: DockPosition) -> bool {
@ -683,12 +637,12 @@ impl Panel for NotificationPanel {
); );
} }
fn size(&self, cx: &gpui::WindowContext) -> f32 { fn size(&self, cx: &gpui::WindowContext) -> Pixels {
self.width self.width
.unwrap_or_else(|| settings::get::<NotificationPanelSettings>(cx).default_width) .unwrap_or_else(|| NotificationPanelSettings::get_global(cx).default_width)
} }
fn set_size(&mut self, size: Option<f32>, cx: &mut ViewContext<Self>) { fn set_size(&mut self, size: Option<Pixels>, cx: &mut ViewContext<Self>) {
self.width = size; self.width = size;
self.serialize(cx); self.serialize(cx);
cx.notify(); cx.notify();
@ -701,17 +655,14 @@ impl Panel for NotificationPanel {
} }
} }
fn icon_path(&self, cx: &gpui::WindowContext) -> Option<&'static str> { fn icon(&self, cx: &gpui::WindowContext) -> Option<Icon> {
(settings::get::<NotificationPanelSettings>(cx).button (NotificationPanelSettings::get_global(cx).button
&& self.notification_store.read(cx).notification_count() > 0) && self.notification_store.read(cx).notification_count() > 0)
.then(|| "icons/bell.svg") .then(|| Icon::Bell)
} }
fn icon_tooltip(&self) -> (String, Option<Box<dyn gpui::Action>>) { fn icon_tooltip(&self, _cx: &WindowContext) -> Option<&'static str> {
( Some("Notification Panel")
"Notification Panel".to_string(),
Some(Box::new(ToggleFocus)),
)
} }
fn icon_label(&self, cx: &WindowContext) -> Option<String> { fn icon_label(&self, cx: &WindowContext) -> Option<String> {
@ -723,20 +674,8 @@ impl Panel for NotificationPanel {
} }
} }
fn should_change_position_on_event(event: &Self::Event) -> bool { fn toggle_action(&self) -> Box<dyn gpui::Action> {
matches!(event, Event::DockPositionChanged) Box::new(ToggleFocus)
}
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)
} }
} }
@ -744,18 +683,14 @@ pub struct NotificationToast {
notification_id: u64, notification_id: u64,
actor: Option<Arc<User>>, actor: Option<Arc<User>>,
text: String, text: String,
workspace: WeakViewHandle<Workspace>, workspace: WeakView<Workspace>,
}
pub enum ToastEvent {
Dismiss,
} }
impl NotificationToast { impl NotificationToast {
fn focus_notification_panel(&self, cx: &mut AppContext) { fn focus_notification_panel(&self, cx: &mut ViewContext<Self>) {
let workspace = self.workspace.clone(); let workspace = self.workspace.clone();
let notification_id = self.notification_id; let notification_id = self.notification_id;
cx.defer(move |cx| { cx.window_context().defer(move |cx| {
workspace workspace
.update(cx, |workspace, cx| { .update(cx, |workspace, cx| {
if let Some(panel) = workspace.focus_panel::<NotificationPanel>(cx) { if let Some(panel) = workspace.focus_panel::<NotificationPanel>(cx) {
@ -772,90 +707,26 @@ impl NotificationToast {
} }
} }
impl Entity for NotificationToast { impl Render for NotificationToast {
type Event = ToastEvent; fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
}
impl View for NotificationToast {
fn ui_name() -> &'static str {
"ContactNotification"
}
fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
let user = self.actor.clone(); let user = self.actor.clone();
let theme = theme::current(cx).clone();
let theme = &theme.contact_notification;
MouseEventHandler::new::<Self, _>(0, cx, |_, cx| { h_stack()
Flex::row() .id("notification_panel_toast")
.with_children(user.and_then(|user| { .children(user.map(|user| Avatar::new(user.avatar_uri.clone())))
Some( .child(Label::new(self.text.clone()))
Image::from_data(user.avatar.clone()?) .child(
.with_style(theme.header_avatar) IconButton::new("close", Icon::Close)
.aligned() .on_click(cx.listener(|_, _, cx| cx.emit(DismissEvent))),
.constrained() )
.with_height( .on_click(cx.listener(|this, _, cx| {
cx.font_cache() this.focus_notification_panel(cx);
.line_height(theme.header_message.text.font_size), cx.emit(DismissEvent);
) }))
.aligned()
.top(),
)
}))
.with_child(
Text::new(self.text.clone(), theme.header_message.text.clone())
.contained()
.with_style(theme.header_message.container)
.aligned()
.top()
.left()
.flex(1., true),
)
.with_child(
MouseEventHandler::new::<ToastEvent, _>(0, cx, |state, _| {
let style = theme.dismiss_button.style_for(state);
Svg::new("icons/x.svg")
.with_color(style.color)
.constrained()
.with_width(style.icon_width)
.aligned()
.contained()
.with_style(style.container)
.constrained()
.with_width(style.button_width)
.with_height(style.button_width)
})
.with_cursor_style(CursorStyle::PointingHand)
.with_padding(Padding::uniform(5.))
.on_click(MouseButton::Left, move |_, _, cx| {
cx.emit(ToastEvent::Dismiss)
})
.aligned()
.constrained()
.with_height(
cx.font_cache()
.line_height(theme.header_message.text.font_size),
)
.aligned()
.top()
.flex_float(),
)
.contained()
})
.with_cursor_style(CursorStyle::PointingHand)
.on_click(MouseButton::Left, move |_, this, cx| {
this.focus_notification_panel(cx);
cx.emit(ToastEvent::Dismiss);
})
.into_any()
} }
} }
impl workspace::notifications::Notification for NotificationToast { impl EventEmitter<DismissEvent> for NotificationToast {}
fn should_dismiss_notification_on_event(&self, event: &<Self as Entity>::Event) -> bool {
matches!(event, ToastEvent::Dismiss)
}
}
fn format_timestamp( fn format_timestamp(
mut timestamp: OffsetDateTime, mut timestamp: OffsetDateTime,

View file

@ -1,14 +1,15 @@
use crate::notification_window_options; use crate::notification_window_options;
use call::{ActiveCall, IncomingCall}; use call::{ActiveCall, IncomingCall};
use client::proto;
use futures::StreamExt; use futures::StreamExt;
use gpui::{ use gpui::{
elements::*, img, px, AppContext, ParentElement, Render, RenderOnce, Styled, ViewContext,
geometry::vector::vec2f, VisualContext as _, WindowHandle,
platform::{CursorStyle, MouseButton},
AnyElement, AppContext, Entity, View, ViewContext, WindowHandle,
}; };
use settings::Settings;
use std::sync::{Arc, Weak}; use std::sync::{Arc, Weak};
use theme::ThemeSettings;
use ui::prelude::*;
use ui::{h_stack, v_stack, Button, Label};
use util::ResultExt; use util::ResultExt;
use workspace::AppState; use workspace::AppState;
@ -19,21 +20,33 @@ pub fn init(app_state: &Arc<AppState>, cx: &mut AppContext) {
let mut notification_windows: Vec<WindowHandle<IncomingCallNotification>> = Vec::new(); let mut notification_windows: Vec<WindowHandle<IncomingCallNotification>> = Vec::new();
while let Some(incoming_call) = incoming_call.next().await { while let Some(incoming_call) = incoming_call.next().await {
for window in notification_windows.drain(..) { for window in notification_windows.drain(..) {
window.remove(&mut cx); window
.update(&mut cx, |_, cx| {
// todo!()
cx.remove_window();
})
.log_err();
} }
if let Some(incoming_call) = incoming_call { if let Some(incoming_call) = incoming_call {
let window_size = cx.read(|cx| { let unique_screens = cx.update(|cx| cx.displays()).unwrap();
let theme = &theme::current(cx).incoming_call_notification; let window_size = gpui::Size {
vec2f(theme.window_width, theme.window_height) width: px(380.),
}); height: px(64.),
};
for screen in cx.platform().screens() { for screen in unique_screens {
let options = notification_window_options(screen, window_size);
let window = cx let window = cx
.add_window(notification_window_options(screen, window_size), |_| { .open_window(options, |cx| {
IncomingCallNotification::new(incoming_call.clone(), app_state.clone()) cx.new_view(|_| {
}); IncomingCallNotification::new(
incoming_call.clone(),
app_state.clone(),
)
})
})
.unwrap();
notification_windows.push(window); notification_windows.push(window);
} }
} }
@ -47,167 +60,104 @@ struct RespondToCall {
accept: bool, accept: bool,
} }
pub struct IncomingCallNotification { struct IncomingCallNotificationState {
call: IncomingCall, call: IncomingCall,
app_state: Weak<AppState>, app_state: Weak<AppState>,
} }
impl IncomingCallNotification { pub struct IncomingCallNotification {
state: Arc<IncomingCallNotificationState>,
}
impl IncomingCallNotificationState {
pub fn new(call: IncomingCall, app_state: Weak<AppState>) -> Self { pub fn new(call: IncomingCall, app_state: Weak<AppState>) -> Self {
Self { call, app_state } Self { call, app_state }
} }
fn respond(&mut self, accept: bool, cx: &mut ViewContext<Self>) { fn respond(&self, accept: bool, cx: &mut AppContext) {
let active_call = ActiveCall::global(cx); let active_call = ActiveCall::global(cx);
if accept { if accept {
let join = active_call.update(cx, |active_call, cx| active_call.accept_incoming(cx)); let join = active_call.update(cx, |active_call, cx| active_call.accept_incoming(cx));
let caller_user_id = self.call.calling_user.id; let caller_user_id = self.call.calling_user.id;
let initial_project_id = self.call.initial_project.as_ref().map(|project| project.id); let initial_project_id = self.call.initial_project.as_ref().map(|project| project.id);
let app_state = self.app_state.clone(); let app_state = self.app_state.clone();
cx.app_context() let cx: &mut AppContext = cx;
.spawn(|mut cx| async move { cx.spawn(|cx| async move {
join.await?; join.await?;
if let Some(project_id) = initial_project_id { if let Some(project_id) = initial_project_id {
cx.update(|cx| { cx.update(|cx| {
if let Some(app_state) = app_state.upgrade() { if let Some(app_state) = app_state.upgrade() {
workspace::join_remote_project( workspace::join_remote_project(
project_id, project_id,
caller_user_id, caller_user_id,
app_state, app_state,
cx, cx,
) )
.detach_and_log_err(cx); .detach_and_log_err(cx);
} }
}); })
} .log_err();
anyhow::Ok(()) }
}) anyhow::Ok(())
.detach_and_log_err(cx); })
.detach_and_log_err(cx);
} else { } else {
active_call.update(cx, |active_call, cx| { active_call.update(cx, |active_call, cx| {
active_call.decline_incoming(cx).log_err(); active_call.decline_incoming(cx).log_err();
}); });
} }
} }
}
fn render_caller(&self, cx: &mut ViewContext<Self>) -> AnyElement<Self> { impl IncomingCallNotification {
let theme = &theme::current(cx).incoming_call_notification; pub fn new(call: IncomingCall, app_state: Weak<AppState>) -> Self {
let default_project = proto::ParticipantProject::default(); Self {
let initial_project = self state: Arc::new(IncomingCallNotificationState::new(call, app_state)),
.call }
.initial_project
.as_ref()
.unwrap_or(&default_project);
Flex::row()
.with_children(self.call.calling_user.avatar.clone().map(|avatar| {
Image::from_data(avatar)
.with_style(theme.caller_avatar)
.aligned()
}))
.with_child(
Flex::column()
.with_child(
Label::new(
self.call.calling_user.github_login.clone(),
theme.caller_username.text.clone(),
)
.contained()
.with_style(theme.caller_username.container),
)
.with_child(
Label::new(
format!(
"is sharing a project in Zed{}",
if initial_project.worktree_root_names.is_empty() {
""
} else {
":"
}
),
theme.caller_message.text.clone(),
)
.contained()
.with_style(theme.caller_message.container),
)
.with_children(if initial_project.worktree_root_names.is_empty() {
None
} else {
Some(
Label::new(
initial_project.worktree_root_names.join(", "),
theme.worktree_roots.text.clone(),
)
.contained()
.with_style(theme.worktree_roots.container),
)
})
.contained()
.with_style(theme.caller_metadata)
.aligned(),
)
.contained()
.with_style(theme.caller_container)
.flex(1., true)
.into_any()
}
fn render_buttons(&self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
enum Accept {}
enum Decline {}
let theme = theme::current(cx);
Flex::column()
.with_child(
MouseEventHandler::new::<Accept, _>(0, cx, |_, _| {
let theme = &theme.incoming_call_notification;
Label::new("Accept", theme.accept_button.text.clone())
.aligned()
.contained()
.with_style(theme.accept_button.container)
})
.with_cursor_style(CursorStyle::PointingHand)
.on_click(MouseButton::Left, |_, this, cx| {
this.respond(true, cx);
})
.flex(1., true),
)
.with_child(
MouseEventHandler::new::<Decline, _>(0, cx, |_, _| {
let theme = &theme.incoming_call_notification;
Label::new("Decline", theme.decline_button.text.clone())
.aligned()
.contained()
.with_style(theme.decline_button.container)
})
.with_cursor_style(CursorStyle::PointingHand)
.on_click(MouseButton::Left, |_, this, cx| {
this.respond(false, cx);
})
.flex(1., true),
)
.constrained()
.with_width(theme.incoming_call_notification.button_width)
.into_any()
} }
} }
impl Entity for IncomingCallNotification { impl Render for IncomingCallNotification {
type Event = (); fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
} // TODO: Is there a better place for us to initialize the font?
let (ui_font, ui_font_size) = {
let theme_settings = ThemeSettings::get_global(cx);
(
theme_settings.ui_font.family.clone(),
theme_settings.ui_font_size.clone(),
)
};
impl View for IncomingCallNotification { cx.set_rem_size(ui_font_size);
fn ui_name() -> &'static str {
"IncomingCallNotification"
}
fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> { h_stack()
let background = theme::current(cx).incoming_call_notification.background; .font(ui_font)
Flex::row() .text_ui()
.with_child(self.render_caller(cx)) .justify_between()
.with_child(self.render_buttons(cx)) .size_full()
.contained() .overflow_hidden()
.with_background_color(background) .elevation_3(cx)
.expanded() .p_2()
.into_any() .gap_2()
.child(
img(self.state.call.calling_user.avatar_uri.clone())
.w_12()
.h_12()
.rounded_full(),
)
.child(v_stack().overflow_hidden().child(Label::new(format!(
"{} is sharing a project in Zed",
self.state.call.calling_user.github_login
))))
.child(
v_stack()
.child(Button::new("accept", "Accept").render(cx).on_click({
let state = self.state.clone();
move |_, cx| state.respond(true, cx)
}))
.child(Button::new("decline", "Decline").render(cx).on_click({
let state = self.state.clone();
move |_, cx| state.respond(false, cx)
})),
)
} }
} }

View file

@ -2,13 +2,11 @@ use crate::notification_window_options;
use call::{room, ActiveCall}; use call::{room, ActiveCall};
use client::User; use client::User;
use collections::HashMap; use collections::HashMap;
use gpui::{ use gpui::{img, px, AppContext, ParentElement, Render, Size, Styled, ViewContext, VisualContext};
elements::*, use settings::Settings;
geometry::vector::vec2f,
platform::{CursorStyle, MouseButton},
AppContext, Entity, View, ViewContext,
};
use std::sync::{Arc, Weak}; use std::sync::{Arc, Weak};
use theme::ThemeSettings;
use ui::{h_stack, prelude::*, v_stack, Button, Label};
use workspace::AppState; use workspace::AppState;
pub fn init(app_state: &Arc<AppState>, cx: &mut AppContext) { pub fn init(app_state: &Arc<AppState>, cx: &mut AppContext) {
@ -21,38 +19,54 @@ pub fn init(app_state: &Arc<AppState>, cx: &mut AppContext) {
project_id, project_id,
worktree_root_names, worktree_root_names,
} => { } => {
let theme = &theme::current(cx).project_shared_notification; let window_size = Size {
let window_size = vec2f(theme.window_width, theme.window_height); width: px(400.),
height: px(72.),
};
for screen in cx.platform().screens() { for screen in cx.displays() {
let window = let options = notification_window_options(screen, window_size);
cx.add_window(notification_window_options(screen, window_size), |_| { let window = cx.open_window(options, |cx| {
cx.new_view(|_| {
ProjectSharedNotification::new( ProjectSharedNotification::new(
owner.clone(), owner.clone(),
*project_id, *project_id,
worktree_root_names.clone(), worktree_root_names.clone(),
app_state.clone(), app_state.clone(),
) )
}); })
});
notification_windows notification_windows
.entry(*project_id) .entry(*project_id)
.or_insert(Vec::new()) .or_insert(Vec::new())
.push(window); .push(window);
} }
} }
room::Event::RemoteProjectUnshared { project_id } room::Event::RemoteProjectUnshared { project_id }
| room::Event::RemoteProjectJoined { project_id } | room::Event::RemoteProjectJoined { project_id }
| room::Event::RemoteProjectInvitationDiscarded { project_id } => { | room::Event::RemoteProjectInvitationDiscarded { project_id } => {
if let Some(windows) = notification_windows.remove(&project_id) { if let Some(windows) = notification_windows.remove(&project_id) {
for window in windows { for window in windows {
window.remove(cx); window
.update(cx, |_, cx| {
// todo!()
cx.remove_window();
})
.ok();
} }
} }
} }
room::Event::Left => { room::Event::Left => {
for (_, windows) in notification_windows.drain() { for (_, windows) in notification_windows.drain() {
for window in windows { for window in windows {
window.remove(cx); window
.update(cx, |_, cx| {
// todo!()
cx.remove_window();
})
.ok();
} }
} }
} }
@ -101,117 +115,66 @@ impl ProjectSharedNotification {
}); });
} }
} }
}
fn render_owner(&self, cx: &mut ViewContext<Self>) -> AnyElement<Self> { impl Render for ProjectSharedNotification {
let theme = &theme::current(cx).project_shared_notification; fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
Flex::row() // TODO: Is there a better place for us to initialize the font?
.with_children(self.owner.avatar.clone().map(|avatar| { let (ui_font, ui_font_size) = {
Image::from_data(avatar) let theme_settings = ThemeSettings::get_global(cx);
.with_style(theme.owner_avatar) (
.aligned() theme_settings.ui_font.family.clone(),
})) theme_settings.ui_font_size.clone(),
.with_child( )
Flex::column() };
.with_child(
Label::new( cx.set_rem_size(ui_font_size);
self.owner.github_login.clone(),
theme.owner_username.text.clone(), h_stack()
) .font(ui_font)
.contained() .text_ui()
.with_style(theme.owner_username.container), .justify_between()
) .size_full()
.with_child( .overflow_hidden()
Label::new( .elevation_3(cx)
format!( .p_2()
"is sharing a project in Zed{}", .gap_2()
if self.worktree_root_names.is_empty() { .child(
"" img(self.owner.avatar_uri.clone())
} else { .w_12()
":" .h_12()
} .rounded_full(),
), )
theme.message.text.clone(), .child(
) v_stack()
.contained() .overflow_hidden()
.with_style(theme.message.container), .child(Label::new(self.owner.github_login.clone()))
) .child(Label::new(format!(
.with_children(if self.worktree_root_names.is_empty() { "is sharing a project in Zed{}",
if self.worktree_root_names.is_empty() {
""
} else {
":"
}
)))
.children(if self.worktree_root_names.is_empty() {
None None
} else { } else {
Some( Some(Label::new(self.worktree_root_names.join(", ")))
Label::new( }),
self.worktree_root_names.join(", "),
theme.worktree_roots.text.clone(),
)
.contained()
.with_style(theme.worktree_roots.container),
)
})
.contained()
.with_style(theme.owner_metadata)
.aligned(),
) )
.contained() .child(
.with_style(theme.owner_container) v_stack()
.flex(1., true) .child(Button::new("open", "Open").on_click(cx.listener(
.into_any() move |this, _event, cx| {
} this.join(cx);
},
fn render_buttons(&self, cx: &mut ViewContext<Self>) -> AnyElement<Self> { )))
enum Open {} .child(Button::new("dismiss", "Dismiss").on_click(cx.listener(
enum Dismiss {} move |this, _event, cx| {
this.dismiss(cx);
let theme = theme::current(cx); },
Flex::column() ))),
.with_child(
MouseEventHandler::new::<Open, _>(0, cx, |_, _| {
let theme = &theme.project_shared_notification;
Label::new("Open", theme.open_button.text.clone())
.aligned()
.contained()
.with_style(theme.open_button.container)
})
.with_cursor_style(CursorStyle::PointingHand)
.on_click(MouseButton::Left, move |_, this, cx| this.join(cx))
.flex(1., true),
) )
.with_child(
MouseEventHandler::new::<Dismiss, _>(0, cx, |_, _| {
let theme = &theme.project_shared_notification;
Label::new("Dismiss", theme.dismiss_button.text.clone())
.aligned()
.contained()
.with_style(theme.dismiss_button.container)
})
.with_cursor_style(CursorStyle::PointingHand)
.on_click(MouseButton::Left, |_, this, cx| {
this.dismiss(cx);
})
.flex(1., true),
)
.constrained()
.with_width(theme.project_shared_notification.button_width)
.into_any()
}
}
impl Entity for ProjectSharedNotification {
type Event = ();
}
impl View for ProjectSharedNotification {
fn ui_name() -> &'static str {
"ProjectSharedNotification"
}
fn render(&mut self, cx: &mut ViewContext<Self>) -> gpui::AnyElement<Self> {
let background = theme::current(cx).project_shared_notification.background;
Flex::row()
.with_child(self.render_owner(cx))
.with_child(self.render_buttons(cx))
.contained()
.with_background_color(background)
.expanded()
.into_any()
} }
} }

View file

@ -1,28 +1,29 @@
use anyhow; use anyhow;
use gpui::Pixels;
use schemars::JsonSchema; use schemars::JsonSchema;
use serde_derive::{Deserialize, Serialize}; use serde_derive::{Deserialize, Serialize};
use settings::Setting; use settings::Settings;
use workspace::dock::DockPosition; use workspace::dock::DockPosition;
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]
pub struct CollaborationPanelSettings { pub struct CollaborationPanelSettings {
pub button: bool, pub button: bool,
pub dock: DockPosition, pub dock: DockPosition,
pub default_width: f32, pub default_width: Pixels,
} }
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]
pub struct ChatPanelSettings { pub struct ChatPanelSettings {
pub button: bool, pub button: bool,
pub dock: DockPosition, pub dock: DockPosition,
pub default_width: f32, pub default_width: Pixels,
} }
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]
pub struct NotificationPanelSettings { pub struct NotificationPanelSettings {
pub button: bool, pub button: bool,
pub dock: DockPosition, pub dock: DockPosition,
pub default_width: f32, pub default_width: Pixels,
} }
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)] #[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)]
@ -32,37 +33,37 @@ pub struct PanelSettingsContent {
pub default_width: Option<f32>, pub default_width: Option<f32>,
} }
impl Setting for CollaborationPanelSettings { impl Settings for CollaborationPanelSettings {
const KEY: Option<&'static str> = Some("collaboration_panel"); const KEY: Option<&'static str> = Some("collaboration_panel");
type FileContent = PanelSettingsContent; type FileContent = PanelSettingsContent;
fn load( fn load(
default_value: &Self::FileContent, default_value: &Self::FileContent,
user_values: &[&Self::FileContent], user_values: &[&Self::FileContent],
_: &gpui::AppContext, _: &mut gpui::AppContext,
) -> anyhow::Result<Self> { ) -> anyhow::Result<Self> {
Self::load_via_json_merge(default_value, user_values) Self::load_via_json_merge(default_value, user_values)
} }
} }
impl Setting for ChatPanelSettings { impl Settings for ChatPanelSettings {
const KEY: Option<&'static str> = Some("chat_panel"); const KEY: Option<&'static str> = Some("chat_panel");
type FileContent = PanelSettingsContent; type FileContent = PanelSettingsContent;
fn load( fn load(
default_value: &Self::FileContent, default_value: &Self::FileContent,
user_values: &[&Self::FileContent], user_values: &[&Self::FileContent],
_: &gpui::AppContext, _: &mut gpui::AppContext,
) -> anyhow::Result<Self> { ) -> anyhow::Result<Self> {
Self::load_via_json_merge(default_value, user_values) Self::load_via_json_merge(default_value, user_values)
} }
} }
impl Setting for NotificationPanelSettings { impl Settings for NotificationPanelSettings {
const KEY: Option<&'static str> = Some("notification_panel"); const KEY: Option<&'static str> = Some("notification_panel");
type FileContent = PanelSettingsContent; type FileContent = PanelSettingsContent;
fn load( fn load(
default_value: &Self::FileContent, default_value: &Self::FileContent,
user_values: &[&Self::FileContent], user_values: &[&Self::FileContent],
_: &gpui::AppContext, _: &mut gpui::AppContext,
) -> anyhow::Result<Self> { ) -> anyhow::Result<Self> {
Self::load_via_json_merge(default_value, user_values) Self::load_via_json_merge(default_value, user_values)
} }

View file

@ -1,81 +0,0 @@
[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 = { package = "auto_update2", path = "../auto_update2" }
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 = { package = "feedback2", path = "../feedback2" }
fuzzy = { package = "fuzzy2", path = "../fuzzy2" }
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 = { package = "recent_projects2", path = "../recent_projects2" }
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 = { package = "theme_selector2", path = "../theme_selector2" }
vcs_menu = { package = "vcs_menu2", path = "../vcs_menu2" }
ui = { package = "ui2", path = "../ui2" }
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

View file

@ -1,448 +0,0 @@
use 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, EditorEvent};
use gpui::{
actions, AnyElement, AnyView, AppContext, Entity as _, EventEmitter, FocusableView,
IntoElement as _, Model, Pixels, Point, Render, Subscription, Task, View, ViewContext,
VisualContext as _, WindowContext,
};
use project::Project;
use std::{
any::{Any, TypeId},
sync::Arc,
};
use ui::{prelude::*, Label};
use util::ResultExt;
use workspace::{
item::{FollowableItem, Item, ItemEvent, ItemHandle},
register_followable_item,
searchable::SearchableItemHandle,
ItemNavHistory, Pane, SaveIntent, ViewId, Workspace, WorkspaceId,
};
actions!(collab, [Deploy]);
pub fn init(cx: &mut AppContext) {
register_followable_item::<ChannelView>(cx)
}
pub struct ChannelView {
pub editor: View<Editor>,
project: Model<Project>,
channel_store: Model<ChannelStore>,
channel_buffer: Model<ChannelBuffer>,
remote_id: Option<ViewId>,
_editor_event_subscription: Subscription,
}
impl ChannelView {
pub fn open(
channel_id: ChannelId,
workspace: View<Workspace>,
cx: &mut WindowContext,
) -> Task<Result<View<Self>>> {
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: View<Pane>,
workspace: View<Workspace>,
cx: &mut WindowContext,
) -> Task<Result<View<Self>>> {
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::<Self>()
.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.new_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.entity_id(), SaveIntent::Skip, cx)
.detach();
pane.add_item(Box::new(view.clone()), true, true, Some(ix), cx);
}
}
view
})
})
}
pub fn new(
project: Model<Project>,
channel_store: Model<ChannelStore>,
channel_buffer: Model<ChannelBuffer>,
cx: &mut ViewContext<Self>,
) -> Self {
let buffer = channel_buffer.read(cx).buffer();
let editor = cx.new_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: &EditorEvent, 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<Arc<Channel>> {
self.channel_buffer.read(cx).channel(cx)
}
fn handle_channel_buffer_event(
&mut self,
_: Model<ChannelBuffer>,
event: &ChannelBufferEvent,
cx: &mut ViewContext<Self>,
) {
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::EditorEvent::TitleChanged);
cx.notify()
});
}
ChannelBufferEvent::BufferEdited => {
if self.editor.read(cx).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 EventEmitter<EditorEvent> for ChannelView {}
impl Render for ChannelView {
fn render(&mut self, _cx: &mut ViewContext<Self>) -> impl IntoElement {
self.editor.clone()
}
}
impl FocusableView for ChannelView {
fn focus_handle(&self, cx: &AppContext) -> gpui::FocusHandle {
self.editor.read(cx).focus_handle(cx)
}
}
impl Item for ChannelView {
type Event = EditorEvent;
fn act_as_type<'a>(
&'a self,
type_id: TypeId,
self_handle: &'a View<Self>,
_: &'a AppContext,
) -> Option<AnyView> {
if type_id == TypeId::of::<Self>() {
Some(self_handle.to_any())
} else if type_id == TypeId::of::<Editor>() {
Some(self.editor.to_any())
} else {
None
}
}
fn tab_content(&self, _: Option<usize>, selected: bool, cx: &WindowContext) -> 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)
.color(if selected {
Color::Default
} else {
Color::Muted
})
.into_any_element()
}
fn clone_on_split(&self, _: WorkspaceId, cx: &mut ViewContext<Self>) -> Option<View<Self>> {
Some(cx.new_view(|cx| {
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<dyn Any>, cx: &mut ViewContext<Self>) -> bool {
self.editor
.update(cx, |editor, cx| editor.navigate(data, cx))
}
fn deactivated(&mut self, cx: &mut ViewContext<Self>) {
self.editor
.update(cx, |editor, cx| Item::deactivated(editor, cx))
}
fn set_nav_history(&mut self, history: ItemNavHistory, cx: &mut ViewContext<Self>) {
self.editor
.update(cx, |editor, cx| Item::set_nav_history(editor, history, cx))
}
fn as_searchable(&self, _: &View<Self>) -> Option<Box<dyn SearchableItemHandle>> {
Some(Box::new(self.editor.clone()))
}
fn show_toolbar(&self) -> bool {
true
}
fn pixel_position_of_cursor(&self, cx: &AppContext) -> Option<Point<Pixels>> {
self.editor.read(cx).pixel_position_of_cursor(cx)
}
fn to_item_events(event: &EditorEvent, f: impl FnMut(ItemEvent)) {
Editor::to_item_events(event, f)
}
}
impl FollowableItem for ChannelView {
fn remote_id(&self) -> Option<workspace::ViewId> {
self.remote_id
}
fn to_state_proto(&self, cx: &WindowContext) -> Option<proto::view::Variant> {
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: View<workspace::Pane>,
workspace: View<workspace::Workspace>,
remote_id: workspace::ViewId,
state: &mut Option<proto::view::Variant>,
cx: &mut WindowContext,
) -> Option<gpui::Task<anyhow::Result<View<Self>>>> {
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
}
})?;
if let Some(task) = task {
task.await?;
}
Ok(this)
}))
}
fn add_event_to_update_proto(
&self,
event: &EditorEvent,
update: &mut Option<proto::update_view::Variant>,
cx: &WindowContext,
) -> bool {
self.editor
.read(cx)
.add_event_to_update_proto(event, update, cx)
}
fn apply_update_proto(
&mut self,
project: &Model<Project>,
message: proto::update_view::Variant,
cx: &mut ViewContext<Self>,
) -> gpui::Task<anyhow::Result<()>> {
self.editor.update(cx, |editor, cx| {
editor.apply_update_proto(project, message, cx)
})
}
fn set_leader_peer_id(&mut self, leader_peer_id: Option<PeerId>, cx: &mut ViewContext<Self>) {
self.editor.update(cx, |editor, cx| {
editor.set_leader_peer_id(leader_peer_id, cx)
})
}
fn is_project_item(&self, _cx: &WindowContext) -> bool {
false
}
fn to_follow_event(event: &Self::Event) -> Option<workspace::item::FollowEvent> {
Editor::to_follow_event(event)
}
}
struct ChannelBufferCollaborationHub(Model<ChannelBuffer>);
impl CollaborationHub for ChannelBufferCollaborationHub {
fn collaborators<'a>(&self, cx: &'a AppContext) -> &'a HashMap<PeerId, Collaborator> {
self.0.read(cx).collaborators()
}
fn user_participant_indices<'a>(
&self,
cx: &'a AppContext,
) -> &'a HashMap<u64, ParticipantIndex> {
self.0.read(cx).user_store().read(cx).participant_indices()
}
}

View file

@ -1,704 +0,0 @@
use crate::{channel_view::ChannelView, is_channels_feature_enabled, 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, div, list, prelude::*, px, serde_json, AnyElement, AppContext, AsyncWindowContext,
ClickEvent, ElementId, EventEmitter, FocusableView, ListOffset, ListScrollEvent, ListState,
Model, Render, Subscription, Task, View, ViewContext, VisualContext, WeakView,
};
use language::LanguageRegistry;
use menu::Confirm;
use message_editor::MessageEditor;
use project::Fs;
use rich_text::RichText;
use serde::{Deserialize, Serialize};
use settings::{Settings, SettingsStore};
use std::sync::Arc;
use theme::ActiveTheme as _;
use time::{OffsetDateTime, UtcOffset};
use ui::{prelude::*, Avatar, Button, Icon, IconButton, Label, TabBar, Tooltip};
use util::{ResultExt, TryFutureExt};
use workspace::{
dock::{DockPosition, Panel, PanelEvent},
Workspace,
};
mod message_editor;
const MESSAGE_LOADING_THRESHOLD: usize = 50;
const CHAT_PANEL_KEY: &'static str = "ChatPanel";
pub fn init(cx: &mut AppContext) {
cx.observe_new_views(|workspace: &mut Workspace, _| {
workspace.register_action(|workspace, _: &ToggleFocus, cx| {
workspace.toggle_panel_focus::<ChatPanel>(cx);
});
})
.detach();
}
pub struct ChatPanel {
client: Arc<Client>,
channel_store: Model<ChannelStore>,
languages: Arc<LanguageRegistry>,
message_list: ListState,
active_chat: Option<(Model<ChannelChat>, Subscription)>,
input_editor: View<MessageEditor>,
local_timezone: UtcOffset,
fs: Arc<dyn Fs>,
width: Option<Pixels>,
active: bool,
pending_serialization: Task<Option<()>>,
subscriptions: Vec<gpui::Subscription>,
workspace: WeakView<Workspace>,
is_scrolled_to_bottom: bool,
markdown_data: HashMap<ChannelMessageId, RichText>,
}
#[derive(Serialize, Deserialize)]
struct SerializedChatPanel {
width: Option<Pixels>,
}
#[derive(Debug)]
pub enum Event {
DockPositionChanged,
Focus,
Dismissed,
}
actions!(chat_panel, [ToggleFocus]);
impl ChatPanel {
pub fn new(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) -> View<Self> {
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.new_view(|cx| {
MessageEditor::new(
languages.clone(),
channel_store.clone(),
cx.new_view(|cx| Editor::auto_height(4, cx)),
cx,
)
});
let workspace_handle = workspace.weak_handle();
cx.new_view(|cx: &mut ViewContext<Self>| {
let view = cx.view().downgrade();
let message_list =
ListState::new(0, gpui::ListAlignment::Bottom, px(1000.), move |ix, cx| {
if let Some(view) = view.upgrade() {
view.update(cx, |view, cx| {
view.render_message(ix, cx).into_any_element()
})
} else {
div().into_any()
}
});
message_list.set_scroll_handler(cx.listener(|this, event: &ListScrollEvent, cx| {
if event.visible_range.start < MESSAGE_LOADING_THRESHOLD {
this.load_more_messages(cx);
}
this.is_scrolled_to_bottom = event.visible_range.end == event.count;
}));
let mut this = Self {
fs,
client,
channel_store,
languages,
message_list,
active_chat: Default::default(),
pending_serialization: Task::ready(None),
input_editor,
local_timezone: cx.local_timezone(),
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::<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(Event::DockPositionChanged);
}
cx.notify();
},
));
this
})
}
pub fn is_scrolled_to_bottom(&self) -> bool {
self.is_scrolled_to_bottom
}
pub fn active_chat(&self) -> Option<Model<ChannelChat>> {
self.active_chat.as_ref().map(|(chat, _)| chat.clone())
}
pub fn load(
workspace: WeakView<Workspace>,
cx: AsyncWindowContext,
) -> Task<Result<View<Self>>> {
cx.spawn(|mut cx| async move {
let serialized_panel = if let Some(panel) = cx
.background_executor()
.spawn(async move { KEY_VALUE_STORE.read_kvp(CHAT_PANEL_KEY) })
.await
.log_err()
.flatten()
{
Some(serde_json::from_str::<SerializedChatPanel>(&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<Self>) {
let width = self.width;
self.pending_serialization = cx.background_executor().spawn(
async move {
KEY_VALUE_STORE
.write_kvp(
CHAT_PANEL_KEY.into(),
serde_json::to_string(&SerializedChatPanel { width })?,
)
.await?;
anyhow::Ok(())
}
.log_err(),
);
}
fn set_active_chat(&mut self, chat: Model<ChannelChat>, cx: &mut ViewContext<Self>) {
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);
cx.notify();
}
}
fn channel_did_change(
&mut self,
_: Model<ChannelChat>,
event: &ChannelChatEvent,
cx: &mut ViewContext<Self>,
) {
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<Self>) {
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<Self>) -> AnyElement {
v_stack()
.full()
.on_action(cx.listener(Self::send))
.child(
h_stack().z_index(1).child(
TabBar::new("chat_header")
.child(
h_stack()
.w_full()
.h(rems(ui::Tab::HEIGHT_IN_REMS))
.px_2()
.child(Label::new(
self.active_chat
.as_ref()
.and_then(|c| {
Some(format!("#{}", c.0.read(cx).channel(cx)?.name))
})
.unwrap_or_default(),
)),
)
.end_child(
IconButton::new("notes", Icon::File)
.on_click(cx.listener(Self::open_notes))
.tooltip(|cx| Tooltip::text("Open notes", cx)),
)
.end_child(
IconButton::new("call", Icon::AudioOn)
.on_click(cx.listener(Self::join_call))
.tooltip(|cx| Tooltip::text("Join call", cx)),
),
),
)
.child(div().flex_grow().px_2().py_1().map(|this| {
if self.active_chat.is_some() {
this.child(list(self.message_list.clone()).full())
} else {
this
}
}))
.child(
div()
.z_index(1)
.p_2()
.bg(cx.theme().colors().background)
.child(self.input_editor.clone()),
)
.into_any()
}
fn render_message(&mut self, ix: usize, cx: &mut ViewContext<Self>) -> impl IntoElement {
let active_chat = &self.active_chat.as_ref().unwrap().0;
let (message, is_continuation_from_previous, is_continuation_to_next, is_admin) =
active_chat.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 next_message =
active_chat.message(ix.saturating_add(1).min(active_chat.message_count() - 1));
let is_continuation_from_previous = last_message.id != this_message.id
&& last_message.sender.id == this_message.sender.id;
let is_continuation_to_next = this_message.id != next_message.id
&& this_message.sender.id == next_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_from_previous,
is_continuation_to_next,
is_admin,
)
});
let _is_pending = message.is_pending();
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 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
};
let element_id: ElementId = match message.id {
ChannelMessageId::Saved(id) => ("saved-message", id).into(),
ChannelMessageId::Pending(id) => ("pending-message", id).into(),
};
v_stack()
.w_full()
.id(element_id)
.relative()
.overflow_hidden()
.group("")
.when(!is_continuation_from_previous, |this| {
this.child(
h_stack()
.gap_2()
.child(Avatar::new(message.sender.avatar_uri.clone()))
.child(Label::new(message.sender.github_login.clone()))
.child(
Label::new(format_timestamp(
message.timestamp,
now,
self.local_timezone,
))
.color(Color::Muted),
),
)
})
.when(!is_continuation_to_next, |this|
// HACK: This should really be a margin, but margins seem to get collapsed.
this.pb_2())
.child(text.element("body".into(), cx))
.child(
div()
.absolute()
.top_1()
.right_2()
.w_8()
.visible_on_hover("")
.children(message_id_to_remove.map(|message_id| {
IconButton::new(("remove", message_id), Icon::XCircle).on_click(
cx.listener(move |this, _, cx| {
this.remove_message(message_id, cx);
}),
)
})),
)
}
fn render_markdown_with_mentions(
language_registry: &Arc<LanguageRegistry>,
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::<Vec<_>>();
rich_text::render_markdown(message.body.clone(), &mentions, language_registry, None)
}
fn render_sign_in_prompt(&self, cx: &mut ViewContext<Self>) -> AnyElement {
Button::new("sign-in", "Sign in to use chat")
.on_click(cx.listener(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, |_, cx| {
cx.focus_self();
})
.ok();
}
})
.detach();
}))
.into_any_element()
}
fn send(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
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<Self>) {
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, cx: &mut ViewContext<Self>) {
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<u64>,
cx: &mut ViewContext<ChatPanel>,
) -> Task<Result<()>> {
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: px(0.0),
});
cx.notify();
}
})?;
}
}
Ok(())
})
}
fn open_notes(&mut self, _: &ClickEvent, cx: &mut ViewContext<Self>) {
if let Some((chat, _)) = &self.active_chat {
let channel_id = chat.read(cx).channel_id;
if let Some(workspace) = self.workspace.upgrade() {
ChannelView::open(channel_id, workspace, cx).detach();
}
}
}
fn join_call(&mut self, _: &ClickEvent, cx: &mut ViewContext<Self>) {
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);
}
}
}
impl EventEmitter<Event> for ChatPanel {}
impl Render for ChatPanel {
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
div()
.full()
.child(if self.client.user_id().is_some() {
self.render_channel(cx)
} else {
self.render_sign_in_prompt(cx)
})
.min_w(px(150.))
}
}
impl FocusableView for ChatPanel {
fn focus_handle(&self, cx: &AppContext) -> gpui::FocusHandle {
self.input_editor.read(cx).focus_handle(cx)
}
}
impl Panel for ChatPanel {
fn position(&self, cx: &gpui::WindowContext) -> DockPosition {
ChatPanelSettings::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::<ChatPanelSettings>(self.fs.clone(), cx, move |settings| {
settings.dock = Some(position)
});
}
fn size(&self, cx: &gpui::WindowContext) -> Pixels {
self.width
.unwrap_or_else(|| ChatPanelSettings::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 set_active(&mut self, active: bool, cx: &mut ViewContext<Self>) {
self.active = active;
if active {
self.acknowledge_last_message(cx);
if !is_channels_feature_enabled(cx) {
cx.emit(Event::Dismissed);
}
}
}
fn persistent_name() -> &'static str {
"ChatPanel"
}
fn icon(&self, _cx: &WindowContext) -> Option<ui::Icon> {
Some(ui::Icon::MessageBubbles)
}
fn icon_tooltip(&self, _cx: &WindowContext) -> Option<&'static str> {
Some("Chat Panel")
}
fn toggle_action(&self) -> Box<dyn gpui::Action> {
Box::new(ToggleFocus)
}
}
impl EventEmitter<PanelEvent> for ChatPanel {}
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())
}
}
#[cfg(test)]
mod tests {
use super::*;
use gpui::HighlightStyle;
use pretty_assertions::assert_eq;
use rich_text::Highlight;
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_uri: "avatar_fgh".into(),
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», lets «call» «@fgh»", false);
assert_eq!(message.text, body);
assert_eq!(
message.highlights,
vec![
(
ranges[0].clone(),
HighlightStyle {
font_style: Some(gpui::FontStyle::Italic),
..Default::default()
}
.into()
),
(ranges[1].clone(), Highlight::Mention),
(
ranges[2].clone(),
HighlightStyle {
font_weight: Some(gpui::FontWeight::BOLD),
..Default::default()
}
.into()
),
(ranges[3].clone(), Highlight::SelfMention)
]
);
}
}

View file

@ -1,296 +0,0 @@
use channel::{ChannelId, ChannelMembership, ChannelStore, MessageParams};
use client::UserId;
use collections::HashMap;
use editor::{AnchorRangeExt, Editor};
use gpui::{
AsyncWindowContext, FocusableView, IntoElement, Model, Render, SharedString, Task, View,
ViewContext, WeakView,
};
use language::{language_settings::SoftWrap, Buffer, BufferSnapshot, LanguageRegistry};
use lazy_static::lazy_static;
use project::search::SearchQuery;
use std::{sync::Arc, time::Duration};
use workspace::item::ItemHandle;
const MENTIONS_DEBOUNCE_INTERVAL: Duration = Duration::from_millis(50);
lazy_static! {
static ref MENTIONS_SEARCH: SearchQuery =
SearchQuery::regex("@[-_\\w]+", false, false, false, Vec::new(), Vec::new()).unwrap();
}
pub struct MessageEditor {
pub editor: View<Editor>,
channel_store: Model<ChannelStore>,
users: HashMap<String, UserId>,
mentions: Vec<UserId>,
mentions_task: Option<Task<()>>,
channel_id: Option<ChannelId>,
}
impl MessageEditor {
pub fn new(
language_registry: Arc<LanguageRegistry>,
channel_store: Model<ChannelStore>,
editor: View<Editor>,
cx: &mut ViewContext<Self>,
) -> Self {
editor.update(cx, |editor, cx| {
editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx);
});
let buffer = editor
.read(cx)
.buffer()
.read(cx)
.as_singleton()
.expect("message editor must be singleton");
cx.subscribe(&buffer, Self::on_buffer_event).detach();
let markdown = language_registry.language_for_name("Markdown");
cx.spawn(|_, mut cx| async move {
let markdown = markdown.await?;
buffer.update(&mut cx, |buffer, cx| {
buffer.set_language(Some(markdown), cx)
})
})
.detach_and_log_err(cx);
Self {
editor,
channel_store,
users: HashMap::default(),
channel_id: None,
mentions: Vec::new(),
mentions_task: None,
}
}
pub fn set_channel(
&mut self,
channel_id: u64,
channel_name: Option<SharedString>,
cx: &mut ViewContext<Self>,
) {
self.editor.update(cx, |editor, cx| {
if let Some(channel_name) = channel_name {
editor.set_placeholder_text(format!("Message #{}", channel_name), cx);
} else {
editor.set_placeholder_text(format!("Message Channel"), cx);
}
});
self.channel_id = Some(channel_id);
self.refresh_users(cx);
}
pub fn refresh_users(&mut self, cx: &mut ViewContext<Self>) {
if let Some(channel_id) = self.channel_id {
let members = self.channel_store.update(cx, |store, cx| {
store.get_channel_member_details(channel_id, cx)
});
cx.spawn(|this, mut cx| async move {
let members = members.await?;
this.update(&mut cx, |this, cx| this.set_members(members, cx))?;
anyhow::Ok(())
})
.detach_and_log_err(cx);
}
}
pub fn set_members(&mut self, members: Vec<ChannelMembership>, _: &mut ViewContext<Self>) {
self.users.clear();
self.users.extend(
members
.into_iter()
.map(|member| (member.user.github_login.clone(), member.user.id)),
);
}
pub fn take_message(&mut self, cx: &mut ViewContext<Self>) -> MessageParams {
self.editor.update(cx, |editor, cx| {
let highlights = editor.text_highlights::<Self>(cx);
let text = editor.text(cx);
let snapshot = editor.buffer().read(cx).snapshot(cx);
let mentions = if let Some((_, ranges)) = highlights {
ranges
.iter()
.map(|range| range.to_offset(&snapshot))
.zip(self.mentions.iter().copied())
.collect()
} else {
Vec::new()
};
editor.clear(cx);
self.mentions.clear();
MessageParams { text, mentions }
})
}
fn on_buffer_event(
&mut self,
buffer: Model<Buffer>,
event: &language::Event,
cx: &mut ViewContext<Self>,
) {
if let language::Event::Reparsed | language::Event::Edited = event {
let buffer = buffer.read(cx).snapshot();
self.mentions_task = Some(cx.spawn(|this, cx| async move {
cx.background_executor()
.timer(MENTIONS_DEBOUNCE_INTERVAL)
.await;
Self::find_mentions(this, buffer, cx).await;
}));
}
}
async fn find_mentions(
this: WeakView<MessageEditor>,
buffer: BufferSnapshot,
mut cx: AsyncWindowContext,
) {
let (buffer, ranges) = cx
.background_executor()
.spawn(async move {
let ranges = MENTIONS_SEARCH.search(&buffer, None).await;
(buffer, ranges)
})
.await;
this.update(&mut cx, |this, cx| {
let mut anchor_ranges = Vec::new();
let mut mentioned_user_ids = Vec::new();
let mut text = String::new();
this.editor.update(cx, |editor, cx| {
let multi_buffer = editor.buffer().read(cx).snapshot(cx);
for range in ranges {
text.clear();
text.extend(buffer.text_for_range(range.clone()));
if let Some(username) = text.strip_prefix("@") {
if let Some(user_id) = this.users.get(username) {
let start = multi_buffer.anchor_after(range.start);
let end = multi_buffer.anchor_after(range.end);
mentioned_user_ids.push(*user_id);
anchor_ranges.push(start..end);
}
}
}
editor.clear_highlights::<Self>(cx);
editor.highlight_text::<Self>(anchor_ranges, gpui::red().into(), cx)
});
this.mentions = mentioned_user_ids;
this.mentions_task.take();
})
.ok();
}
pub(crate) fn focus_handle(&self, cx: &gpui::AppContext) -> gpui::FocusHandle {
self.editor.read(cx).focus_handle(cx)
}
}
impl Render for MessageEditor {
fn render(&mut self, _cx: &mut ViewContext<Self>) -> impl IntoElement {
self.editor.to_any()
}
}
#[cfg(test)]
mod tests {
use super::*;
use client::{Client, User, UserStore};
use gpui::{Context as _, TestAppContext, VisualContext as _};
use language::{Language, LanguageConfig};
use rpc::proto;
use settings::SettingsStore;
use util::{http::FakeHttpClient, test::marked_text_ranges};
#[gpui::test]
async fn test_message_editor(cx: &mut TestAppContext) {
let language_registry = init_test(cx);
let (editor, cx) = cx.add_window_view(|cx| {
MessageEditor::new(
language_registry,
ChannelStore::global(cx),
cx.new_view(|cx| Editor::auto_height(4, cx)),
cx,
)
});
cx.executor().run_until_parked();
editor.update(cx, |editor, cx| {
editor.set_members(
vec![
ChannelMembership {
user: Arc::new(User {
github_login: "a-b".into(),
id: 101,
avatar_uri: "avatar_a-b".into(),
}),
kind: proto::channel_member::Kind::Member,
role: proto::ChannelRole::Member,
},
ChannelMembership {
user: Arc::new(User {
github_login: "C_D".into(),
id: 102,
avatar_uri: "avatar_C_D".into(),
}),
kind: proto::channel_member::Kind::Member,
role: proto::ChannelRole::Member,
},
],
cx,
);
editor.editor.update(cx, |editor, cx| {
editor.set_text("Hello, @a-b! Have you met @C_D?", cx)
});
});
cx.executor().advance_clock(MENTIONS_DEBOUNCE_INTERVAL);
editor.update(cx, |editor, cx| {
let (text, ranges) = marked_text_ranges("Hello, «@a-b»! Have you met «@C_D»?", false);
assert_eq!(
editor.take_message(cx),
MessageParams {
text,
mentions: vec![(ranges[0].clone(), 101), (ranges[1].clone(), 102)],
}
);
});
}
fn init_test(cx: &mut TestAppContext) -> Arc<LanguageRegistry> {
cx.update(|cx| {
let http = FakeHttpClient::with_404_response();
let client = Client::new(http.clone(), cx);
let user_store = cx.new_model(|cx| UserStore::new(client.clone(), cx));
let settings = SettingsStore::test(cx);
cx.set_global(settings);
theme::init(theme::LoadThemes::JustBase, cx);
language::init(cx);
editor::init(cx);
client::init(&client, cx);
channel::init(&client, user_store, cx);
});
let language_registry = Arc::new(LanguageRegistry::test());
language_registry.add(Arc::new(Language::new(
LanguageConfig {
name: "Markdown".into(),
..Default::default()
},
Some(tree_sitter_markdown::language()),
)));
language_registry
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,575 +0,0 @@
use channel::{ChannelId, ChannelMembership, ChannelStore};
use client::{
proto::{self, ChannelRole, ChannelVisibility},
User, UserId, UserStore,
};
use fuzzy::{match_strings, StringMatchCandidate};
use gpui::{
actions, div, overlay, AppContext, ClipboardItem, DismissEvent, EventEmitter, FocusableView,
Model, ParentElement, Render, Styled, Subscription, Task, View, ViewContext, VisualContext,
WeakView,
};
use picker::{Picker, PickerDelegate};
use std::sync::Arc;
use ui::{prelude::*, Avatar, Checkbox, ContextMenu, ListItem, ListItemSpacing};
use util::TryFutureExt;
use workspace::ModalView;
actions!(
channel_modal,
[
SelectNextControl,
ToggleMode,
ToggleMemberAdmin,
RemoveMember
]
);
pub struct ChannelModal {
picker: View<Picker<ChannelModalDelegate>>,
channel_store: Model<ChannelStore>,
channel_id: ChannelId,
}
impl ChannelModal {
pub fn new(
user_store: Model<UserStore>,
channel_store: Model<ChannelStore>,
channel_id: ChannelId,
mode: Mode,
members: Vec<ChannelMembership>,
cx: &mut ViewContext<Self>,
) -> Self {
cx.observe(&channel_store, |_, _, cx| cx.notify()).detach();
let channel_modal = cx.view().downgrade();
let picker = cx.new_view(|cx| {
Picker::new(
ChannelModalDelegate {
channel_modal,
matching_users: Vec::new(),
matching_member_indices: Vec::new(),
selected_index: 0,
user_store: user_store.clone(),
channel_store: channel_store.clone(),
channel_id,
match_candidates: Vec::new(),
context_menu: None,
members,
mode,
},
cx,
)
.modal(false)
});
Self {
picker,
channel_store,
channel_id,
}
}
fn toggle_mode(&mut self, _: &ToggleMode, cx: &mut ViewContext<Self>) {
let mode = match self.picker.read(cx).delegate.mode {
Mode::ManageMembers => Mode::InviteMembers,
Mode::InviteMembers => Mode::ManageMembers,
};
self.set_mode(mode, cx);
}
fn set_mode(&mut self, mode: Mode, cx: &mut ViewContext<Self>) {
let channel_store = self.channel_store.clone();
let channel_id = self.channel_id;
cx.spawn(|this, mut cx| async move {
if mode == Mode::ManageMembers {
let mut members = channel_store
.update(&mut cx, |channel_store, cx| {
channel_store.get_channel_member_details(channel_id, cx)
})?
.await?;
members.sort_by(|a, b| a.sort_key().cmp(&b.sort_key()));
this.update(&mut cx, |this, cx| {
this.picker
.update(cx, |picker, _| picker.delegate.members = members);
})?;
}
this.update(&mut cx, |this, cx| {
this.picker.update(cx, |picker, cx| {
let delegate = &mut picker.delegate;
delegate.mode = mode;
delegate.selected_index = 0;
picker.set_query("", cx);
picker.update_matches(picker.query(cx), cx);
cx.notify()
});
cx.notify()
})
})
.detach();
}
fn set_channel_visiblity(&mut self, selection: &Selection, cx: &mut ViewContext<Self>) {
self.channel_store.update(cx, |channel_store, cx| {
channel_store
.set_channel_visibility(
self.channel_id,
match selection {
Selection::Unselected => ChannelVisibility::Members,
Selection::Selected => ChannelVisibility::Public,
Selection::Indeterminate => return,
},
cx,
)
.detach_and_log_err(cx)
});
}
fn dismiss(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
cx.emit(DismissEvent);
}
}
impl EventEmitter<DismissEvent> for ChannelModal {}
impl ModalView for ChannelModal {}
impl FocusableView for ChannelModal {
fn focus_handle(&self, cx: &AppContext) -> gpui::FocusHandle {
self.picker.focus_handle(cx)
}
}
impl Render for ChannelModal {
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
let channel_store = self.channel_store.read(cx);
let Some(channel) = channel_store.channel_for_id(self.channel_id) else {
return div();
};
let channel_name = channel.name.clone();
let channel_id = channel.id;
let visibility = channel.visibility;
let mode = self.picker.read(cx).delegate.mode;
v_stack()
.key_context("ChannelModal")
.on_action(cx.listener(Self::toggle_mode))
.on_action(cx.listener(Self::dismiss))
.elevation_3(cx)
.w(rems(34.))
.child(
v_stack()
.px_2()
.py_1()
.rounded_t(px(8.))
.bg(cx.theme().colors().element_background)
.child(IconElement::new(Icon::Hash).size(IconSize::Medium))
.child(Label::new(channel_name))
.child(
h_stack()
.w_full()
.justify_between()
.child(
h_stack()
.gap_2()
.child(
Checkbox::new(
"is-public",
if visibility == ChannelVisibility::Public {
ui::Selection::Selected
} else {
ui::Selection::Unselected
},
)
.on_click(cx.listener(Self::set_channel_visiblity)),
)
.child(Label::new("Public")),
)
.children(if visibility == ChannelVisibility::Public {
Some(Button::new("copy-link", "Copy Link").on_click(cx.listener(
move |this, _, cx| {
if let Some(channel) =
this.channel_store.read(cx).channel_for_id(channel_id)
{
let item = ClipboardItem::new(channel.link());
cx.write_to_clipboard(item);
}
},
)))
} else {
None
}),
)
.child(
div()
.w_full()
.flex()
.flex_row()
.child(
Button::new("manage-members", "Manage Members")
.selected(mode == Mode::ManageMembers)
.on_click(cx.listener(|this, _, cx| {
this.set_mode(Mode::ManageMembers, cx);
})),
)
.child(
Button::new("invite-members", "Invite Members")
.selected(mode == Mode::InviteMembers)
.on_click(cx.listener(|this, _, cx| {
this.set_mode(Mode::InviteMembers, cx);
})),
),
),
)
.child(self.picker.clone())
}
}
#[derive(Copy, Clone, PartialEq)]
pub enum Mode {
ManageMembers,
InviteMembers,
}
pub struct ChannelModalDelegate {
channel_modal: WeakView<ChannelModal>,
matching_users: Vec<Arc<User>>,
matching_member_indices: Vec<usize>,
user_store: Model<UserStore>,
channel_store: Model<ChannelStore>,
channel_id: ChannelId,
selected_index: usize,
mode: Mode,
match_candidates: Vec<StringMatchCandidate>,
members: Vec<ChannelMembership>,
context_menu: Option<(View<ContextMenu>, Subscription)>,
}
impl PickerDelegate for ChannelModalDelegate {
type ListItem = ListItem;
fn placeholder_text(&self) -> Arc<str> {
"Search collaborator by username...".into()
}
fn match_count(&self) -> usize {
match self.mode {
Mode::ManageMembers => self.matching_member_indices.len(),
Mode::InviteMembers => self.matching_users.len(),
}
}
fn selected_index(&self) -> usize {
self.selected_index
}
fn set_selected_index(&mut self, ix: usize, _: &mut ViewContext<Picker<Self>>) {
self.selected_index = ix;
}
fn update_matches(&mut self, query: String, cx: &mut ViewContext<Picker<Self>>) -> Task<()> {
match self.mode {
Mode::ManageMembers => {
self.match_candidates.clear();
self.match_candidates
.extend(self.members.iter().enumerate().map(|(id, member)| {
StringMatchCandidate {
id,
string: member.user.github_login.clone(),
char_bag: member.user.github_login.chars().collect(),
}
}));
let matches = cx.background_executor().block(match_strings(
&self.match_candidates,
&query,
true,
usize::MAX,
&Default::default(),
cx.background_executor().clone(),
));
cx.spawn(|picker, mut cx| async move {
picker
.update(&mut cx, |picker, cx| {
let delegate = &mut picker.delegate;
delegate.matching_member_indices.clear();
delegate
.matching_member_indices
.extend(matches.into_iter().map(|m| m.candidate_id));
cx.notify();
})
.ok();
})
}
Mode::InviteMembers => {
let search_users = self
.user_store
.update(cx, |store, cx| store.fuzzy_search_users(query, cx));
cx.spawn(|picker, mut cx| async move {
async {
let users = search_users.await?;
picker.update(&mut cx, |picker, cx| {
picker.delegate.matching_users = users;
cx.notify();
})?;
anyhow::Ok(())
}
.log_err()
.await;
})
}
}
}
fn confirm(&mut self, _: bool, cx: &mut ViewContext<Picker<Self>>) {
if let Some((selected_user, role)) = self.user_at_index(self.selected_index) {
match self.mode {
Mode::ManageMembers => {
self.show_context_menu(selected_user, role.unwrap_or(ChannelRole::Member), cx)
}
Mode::InviteMembers => match self.member_status(selected_user.id, cx) {
Some(proto::channel_member::Kind::Invitee) => {
self.remove_member(selected_user.id, cx);
}
Some(proto::channel_member::Kind::AncestorMember) | None => {
self.invite_member(selected_user, cx)
}
Some(proto::channel_member::Kind::Member) => {}
},
}
}
}
fn dismissed(&mut self, cx: &mut ViewContext<Picker<Self>>) {
if self.context_menu.is_none() {
self.channel_modal
.update(cx, |_, cx| {
cx.emit(DismissEvent);
})
.ok();
}
}
fn render_match(
&self,
ix: usize,
selected: bool,
cx: &mut ViewContext<Picker<Self>>,
) -> Option<Self::ListItem> {
let (user, role) = self.user_at_index(ix)?;
let request_status = self.member_status(user.id, cx);
Some(
ListItem::new(ix)
.inset(true)
.spacing(ListItemSpacing::Sparse)
.selected(selected)
.start_slot(Avatar::new(user.avatar_uri.clone()))
.child(Label::new(user.github_login.clone()))
.end_slot(h_stack().gap_2().map(|slot| {
match self.mode {
Mode::ManageMembers => slot
.children(
if request_status == Some(proto::channel_member::Kind::Invitee) {
Some(Label::new("Invited"))
} else {
None
},
)
.children(match role {
Some(ChannelRole::Admin) => Some(Label::new("Admin")),
Some(ChannelRole::Guest) => Some(Label::new("Guest")),
_ => None,
})
.child(IconButton::new("ellipsis", Icon::Ellipsis))
.children(
if let (Some((menu, _)), true) = (&self.context_menu, selected) {
Some(
overlay()
.anchor(gpui::AnchorCorner::TopLeft)
.child(menu.clone()),
)
} else {
None
},
),
Mode::InviteMembers => match request_status {
Some(proto::channel_member::Kind::Invitee) => {
slot.children(Some(Label::new("Invited")))
}
Some(proto::channel_member::Kind::Member) => {
slot.children(Some(Label::new("Member")))
}
_ => slot,
},
}
})),
)
}
}
impl ChannelModalDelegate {
fn member_status(
&self,
user_id: UserId,
cx: &AppContext,
) -> Option<proto::channel_member::Kind> {
self.members
.iter()
.find_map(|membership| (membership.user.id == user_id).then_some(membership.kind))
.or_else(|| {
self.channel_store
.read(cx)
.has_pending_channel_invite(self.channel_id, user_id)
.then_some(proto::channel_member::Kind::Invitee)
})
}
fn user_at_index(&self, ix: usize) -> Option<(Arc<User>, Option<ChannelRole>)> {
match self.mode {
Mode::ManageMembers => self.matching_member_indices.get(ix).and_then(|ix| {
let channel_membership = self.members.get(*ix)?;
Some((
channel_membership.user.clone(),
Some(channel_membership.role),
))
}),
Mode::InviteMembers => Some((self.matching_users.get(ix).cloned()?, None)),
}
}
fn set_user_role(
&mut self,
user_id: UserId,
new_role: ChannelRole,
cx: &mut ViewContext<Picker<Self>>,
) -> Option<()> {
let update = self.channel_store.update(cx, |store, cx| {
store.set_member_role(self.channel_id, user_id, new_role, cx)
});
cx.spawn(|picker, mut cx| async move {
update.await?;
picker.update(&mut cx, |picker, cx| {
let this = &mut picker.delegate;
if let Some(member) = this.members.iter_mut().find(|m| m.user.id == user_id) {
member.role = new_role;
}
cx.focus_self();
cx.notify();
})
})
.detach_and_log_err(cx);
Some(())
}
fn remove_member(&mut self, user_id: UserId, cx: &mut ViewContext<Picker<Self>>) -> Option<()> {
let update = self.channel_store.update(cx, |store, cx| {
store.remove_member(self.channel_id, user_id, cx)
});
cx.spawn(|picker, mut cx| async move {
update.await?;
picker.update(&mut cx, |picker, cx| {
let this = &mut picker.delegate;
if let Some(ix) = this.members.iter_mut().position(|m| m.user.id == user_id) {
this.members.remove(ix);
this.matching_member_indices.retain_mut(|member_ix| {
if *member_ix == ix {
return false;
} else if *member_ix > ix {
*member_ix -= 1;
}
true
})
}
this.selected_index = this
.selected_index
.min(this.matching_member_indices.len().saturating_sub(1));
picker.focus(cx);
cx.notify();
})
})
.detach_and_log_err(cx);
Some(())
}
fn invite_member(&mut self, user: Arc<User>, cx: &mut ViewContext<Picker<Self>>) {
let invite_member = self.channel_store.update(cx, |store, cx| {
store.invite_member(self.channel_id, user.id, ChannelRole::Member, cx)
});
cx.spawn(|this, mut cx| async move {
invite_member.await?;
this.update(&mut cx, |this, cx| {
let new_member = ChannelMembership {
user,
kind: proto::channel_member::Kind::Invitee,
role: ChannelRole::Member,
};
let members = &mut this.delegate.members;
match members.binary_search_by_key(&new_member.sort_key(), |k| k.sort_key()) {
Ok(ix) | Err(ix) => members.insert(ix, new_member),
}
cx.notify();
})
})
.detach_and_log_err(cx);
}
fn show_context_menu(
&mut self,
user: Arc<User>,
role: ChannelRole,
cx: &mut ViewContext<Picker<Self>>,
) {
let user_id = user.id;
let picker = cx.view().clone();
let context_menu = ContextMenu::build(cx, |mut menu, _cx| {
menu = menu.entry("Remove Member", None, {
let picker = picker.clone();
move |cx| {
picker.update(cx, |picker, cx| {
picker.delegate.remove_member(user_id, cx);
})
}
});
let picker = picker.clone();
match role {
ChannelRole::Admin => {
menu = menu.entry("Revoke Admin", None, move |cx| {
picker.update(cx, |picker, cx| {
picker
.delegate
.set_user_role(user_id, ChannelRole::Member, cx);
})
});
}
ChannelRole::Member => {
menu = menu.entry("Make Admin", None, move |cx| {
picker.update(cx, |picker, cx| {
picker
.delegate
.set_user_role(user_id, ChannelRole::Admin, cx);
})
});
}
_ => {}
};
menu
});
cx.focus_view(&context_menu);
let subscription = cx.subscribe(&context_menu, |picker, _, _: &DismissEvent, cx| {
picker.delegate.context_menu = None;
picker.focus(cx);
cx.notify();
});
self.context_menu = Some((context_menu, subscription));
}
}

View file

@ -1,163 +0,0 @@
use client::{ContactRequestStatus, User, UserStore};
use gpui::{
AppContext, DismissEvent, EventEmitter, FocusHandle, FocusableView, Model, ParentElement as _,
Render, Styled, Task, View, ViewContext, VisualContext, WeakView,
};
use picker::{Picker, PickerDelegate};
use std::sync::Arc;
use theme::ActiveTheme as _;
use ui::{prelude::*, Avatar, ListItem, ListItemSpacing};
use util::{ResultExt as _, TryFutureExt};
use workspace::ModalView;
pub struct ContactFinder {
picker: View<Picker<ContactFinderDelegate>>,
}
impl ContactFinder {
pub fn new(user_store: Model<UserStore>, cx: &mut ViewContext<Self>) -> Self {
let delegate = ContactFinderDelegate {
parent: cx.view().downgrade(),
user_store,
potential_contacts: Arc::from([]),
selected_index: 0,
};
let picker = cx.new_view(|cx| Picker::new(delegate, cx).modal(false));
Self { picker }
}
pub fn set_query(&mut self, query: String, cx: &mut ViewContext<Self>) {
self.picker.update(cx, |picker, cx| {
picker.set_query(query, cx);
});
}
}
impl Render for ContactFinder {
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
v_stack()
.elevation_3(cx)
.child(
v_stack()
.px_2()
.py_1()
.bg(cx.theme().colors().element_background)
// HACK: Prevent the background color from overflowing the parent container.
.rounded_t(px(8.))
.child(Label::new("Contacts"))
.child(h_stack().child(Label::new("Invite new contacts"))),
)
.child(self.picker.clone())
.w(rems(34.))
}
}
pub struct ContactFinderDelegate {
parent: WeakView<ContactFinder>,
potential_contacts: Arc<[Arc<User>]>,
user_store: Model<UserStore>,
selected_index: usize,
}
impl EventEmitter<DismissEvent> for ContactFinder {}
impl ModalView for ContactFinder {}
impl FocusableView for ContactFinder {
fn focus_handle(&self, cx: &AppContext) -> FocusHandle {
self.picker.focus_handle(cx)
}
}
impl PickerDelegate for ContactFinderDelegate {
type ListItem = ListItem;
fn match_count(&self) -> usize {
self.potential_contacts.len()
}
fn selected_index(&self) -> usize {
self.selected_index
}
fn set_selected_index(&mut self, ix: usize, _: &mut ViewContext<Picker<Self>>) {
self.selected_index = ix;
}
fn placeholder_text(&self) -> Arc<str> {
"Search collaborator by username...".into()
}
fn update_matches(&mut self, query: String, cx: &mut ViewContext<Picker<Self>>) -> Task<()> {
let search_users = self
.user_store
.update(cx, |store, cx| store.fuzzy_search_users(query, cx));
cx.spawn(|picker, mut cx| async move {
async {
let potential_contacts = search_users.await?;
picker.update(&mut cx, |picker, cx| {
picker.delegate.potential_contacts = potential_contacts.into();
cx.notify();
})?;
anyhow::Ok(())
}
.log_err()
.await;
})
}
fn confirm(&mut self, _: bool, cx: &mut ViewContext<Picker<Self>>) {
if let Some(user) = self.potential_contacts.get(self.selected_index) {
let user_store = self.user_store.read(cx);
match user_store.contact_request_status(user) {
ContactRequestStatus::None | ContactRequestStatus::RequestReceived => {
self.user_store
.update(cx, |store, cx| store.request_contact(user.id, cx))
.detach();
}
ContactRequestStatus::RequestSent => {
self.user_store
.update(cx, |store, cx| store.remove_contact(user.id, cx))
.detach();
}
_ => {}
}
}
}
fn dismissed(&mut self, cx: &mut ViewContext<Picker<Self>>) {
self.parent
.update(cx, |_, cx| cx.emit(DismissEvent))
.log_err();
}
fn render_match(
&self,
ix: usize,
selected: bool,
cx: &mut ViewContext<Picker<Self>>,
) -> Option<Self::ListItem> {
let user = &self.potential_contacts[ix];
let request_status = self.user_store.read(cx).contact_request_status(user);
let icon_path = match request_status {
ContactRequestStatus::None | ContactRequestStatus::RequestReceived => {
Some("icons/check.svg")
}
ContactRequestStatus::RequestSent => Some("icons/x.svg"),
ContactRequestStatus::RequestAccepted => None,
};
Some(
ListItem::new(ix)
.inset(true)
.spacing(ListItemSpacing::Sparse)
.selected(selected)
.start_slot(Avatar::new(user.avatar_uri.clone()))
.child(Label::new(user.github_login.clone()))
.end_slot::<IconElement>(
icon_path.map(|icon_path| IconElement::from_path(icon_path)),
),
)
}
}

View file

@ -1,586 +0,0 @@
use crate::face_pile::FacePile;
use auto_update::AutoUpdateStatus;
use call::{ActiveCall, ParticipantLocation, Room};
use client::{proto::PeerId, Client, ParticipantIndex, User, UserStore};
use gpui::{
actions, canvas, div, point, px, rems, Action, AnyElement, AppContext, Element, Hsla,
InteractiveElement, IntoElement, Model, ParentElement, Path, Render,
StatefulInteractiveElement, Styled, Subscription, View, ViewContext, VisualContext, WeakView,
WindowBounds,
};
use project::{Project, RepositoryEntry};
use recent_projects::RecentProjects;
use std::sync::Arc;
use theme::{ActiveTheme, PlayerColors};
use ui::{
h_stack, popover_menu, prelude::*, Avatar, Button, ButtonLike, ButtonStyle, ContextMenu, Icon,
IconButton, IconElement, Tooltip,
};
use util::ResultExt;
use vcs_menu::{build_branch_list, BranchList, OpenRecent as ToggleVcsMenu};
use workspace::{notifications::NotifyResultExt, Workspace};
const MAX_PROJECT_NAME_LENGTH: usize = 40;
const MAX_BRANCH_NAME_LENGTH: usize = 40;
actions!(
collab,
[
ShareProject,
UnshareProject,
ToggleUserMenu,
ToggleProjectMenu,
SwitchBranch
]
);
pub fn init(cx: &mut AppContext) {
cx.observe_new_views(|workspace: &mut Workspace, cx| {
let titlebar_item = cx.new_view(|cx| CollabTitlebarItem::new(workspace, cx));
workspace.set_titlebar_item(titlebar_item.into(), cx)
})
.detach();
// cx.add_action(CollabTitlebarItem::share_project);
// cx.add_action(CollabTitlebarItem::unshare_project);
// cx.add_action(CollabTitlebarItem::toggle_user_menu);
// cx.add_action(CollabTitlebarItem::toggle_vcs_menu);
// cx.add_action(CollabTitlebarItem::toggle_project_menu);
}
pub struct CollabTitlebarItem {
project: Model<Project>,
user_store: Model<UserStore>,
client: Arc<Client>,
workspace: WeakView<Workspace>,
_subscriptions: Vec<Subscription>,
}
impl Render for CollabTitlebarItem {
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
let room = ActiveCall::global(cx).read(cx).room().cloned();
let current_user = self.user_store.read(cx).current_user();
let client = self.client.clone();
let project_id = self.project.read(cx).remote_id();
h_stack()
.id("titlebar")
.justify_between()
.w_full()
.h(rems(1.75))
// Set a non-scaling min-height here to ensure the titlebar is
// always at least the height of the traffic lights.
.min_h(px(32.))
.map(|this| {
if matches!(cx.window_bounds(), WindowBounds::Fullscreen) {
this.pl_2()
} else {
// Use pixels here instead of a rem-based size because the macOS traffic
// lights are a static size, and don't scale with the rest of the UI.
this.pl(px(80.))
}
})
.bg(cx.theme().colors().title_bar_background)
.on_click(|event, cx| {
if event.up.click_count == 2 {
cx.zoom_window();
}
})
// left side
.child(
h_stack()
.gap_1()
.children(self.render_project_host(cx))
.child(self.render_project_name(cx))
.children(self.render_project_branch(cx))
.when_some(
current_user.clone().zip(client.peer_id()).zip(room.clone()),
|this, ((current_user, peer_id), room)| {
let player_colors = cx.theme().players();
let room = room.read(cx);
let mut remote_participants =
room.remote_participants().values().collect::<Vec<_>>();
remote_participants.sort_by_key(|p| p.participant_index.0);
this.children(self.render_collaborator(
&current_user,
peer_id,
true,
room.is_speaking(),
room.is_muted(cx),
&room,
project_id,
&current_user,
))
.children(
remote_participants.iter().filter_map(|collaborator| {
let is_present = project_id.map_or(false, |project_id| {
collaborator.location
== ParticipantLocation::SharedProject { project_id }
});
let face_pile = self.render_collaborator(
&collaborator.user,
collaborator.peer_id,
is_present,
collaborator.speaking,
collaborator.muted,
&room,
project_id,
&current_user,
)?;
Some(
v_stack()
.id(("collaborator", collaborator.user.id))
.child(face_pile)
.child(render_color_ribbon(
collaborator.participant_index,
player_colors,
))
.cursor_pointer()
.on_click({
let peer_id = collaborator.peer_id;
cx.listener(move |this, _, cx| {
this.workspace
.update(cx, |workspace, cx| {
workspace.follow(peer_id, cx);
})
.ok();
})
})
.tooltip({
let login = collaborator.user.github_login.clone();
move |cx| {
Tooltip::text(format!("Follow {login}"), cx)
}
}),
)
}),
)
},
),
)
// right side
.child(
h_stack()
.gap_1()
.pr_1()
.when_some(room, |this, room| {
let room = room.read(cx);
let project = self.project.read(cx);
let is_local = project.is_local();
let is_shared = is_local && project.is_shared();
let is_muted = room.is_muted(cx);
let is_deafened = room.is_deafened().unwrap_or(false);
let is_screen_sharing = room.is_screen_sharing();
this.when(is_local, |this| {
this.child(
Button::new(
"toggle_sharing",
if is_shared { "Unshare" } else { "Share" },
)
.style(ButtonStyle::Subtle)
.label_size(LabelSize::Small)
.on_click(cx.listener(
move |this, _, cx| {
if is_shared {
this.unshare_project(&Default::default(), cx);
} else {
this.share_project(&Default::default(), cx);
}
},
)),
)
})
.child(
IconButton::new("leave-call", ui::Icon::Exit)
.style(ButtonStyle::Subtle)
.icon_size(IconSize::Small)
.on_click(move |_, cx| {
ActiveCall::global(cx)
.update(cx, |call, cx| call.hang_up(cx))
.detach_and_log_err(cx);
}),
)
.child(
IconButton::new(
"mute-microphone",
if is_muted {
ui::Icon::MicMute
} else {
ui::Icon::Mic
},
)
.style(ButtonStyle::Subtle)
.icon_size(IconSize::Small)
.selected(is_muted)
.on_click(move |_, cx| crate::toggle_mute(&Default::default(), cx)),
)
.child(
IconButton::new(
"mute-sound",
if is_deafened {
ui::Icon::AudioOff
} else {
ui::Icon::AudioOn
},
)
.style(ButtonStyle::Subtle)
.icon_size(IconSize::Small)
.selected(is_deafened)
.tooltip(move |cx| {
Tooltip::with_meta("Deafen Audio", None, "Mic will be muted", cx)
})
.on_click(move |_, cx| crate::toggle_mute(&Default::default(), cx)),
)
.child(
IconButton::new("screen-share", ui::Icon::Screen)
.style(ButtonStyle::Subtle)
.icon_size(IconSize::Small)
.selected(is_screen_sharing)
.on_click(move |_, cx| {
crate::toggle_screen_sharing(&Default::default(), cx)
}),
)
})
.map(|el| {
let status = self.client.status();
let status = &*status.borrow();
if matches!(status, client::Status::Connected { .. }) {
el.child(self.render_user_menu_button(cx))
} else {
el.children(self.render_connection_status(status, cx))
.child(self.render_sign_in_button(cx))
.child(self.render_user_menu_button(cx))
}
}),
)
}
}
fn render_color_ribbon(participant_index: ParticipantIndex, colors: &PlayerColors) -> gpui::Canvas {
let color = colors.color_for_participant(participant_index.0).cursor;
canvas(move |bounds, cx| {
let mut path = Path::new(bounds.lower_left());
let height = bounds.size.height;
path.curve_to(bounds.origin + point(height, px(0.)), bounds.origin);
path.line_to(bounds.upper_right() - point(height, px(0.)));
path.curve_to(bounds.lower_right(), bounds.upper_right());
path.line_to(bounds.lower_left());
cx.paint_path(path, color);
})
.h_1()
.w_full()
}
impl CollabTitlebarItem {
pub fn new(workspace: &Workspace, cx: &mut ViewContext<Self>) -> Self {
let project = workspace.project().clone();
let user_store = workspace.app_state().user_store.clone();
let client = workspace.app_state().client.clone();
let active_call = ActiveCall::global(cx);
let mut subscriptions = Vec::new();
subscriptions.push(
cx.observe(&workspace.weak_handle().upgrade().unwrap(), |_, _, cx| {
cx.notify()
}),
);
subscriptions.push(cx.observe(&project, |_, _, cx| cx.notify()));
subscriptions.push(cx.observe(&active_call, |this, _, cx| this.active_call_changed(cx)));
subscriptions.push(cx.observe_window_activation(Self::window_activation_changed));
subscriptions.push(cx.observe(&user_store, |_, _, cx| cx.notify()));
Self {
workspace: workspace.weak_handle(),
project,
user_store,
client,
_subscriptions: subscriptions,
}
}
// resolve if you are in a room -> render_project_owner
// render_project_owner -> resolve if you are in a room -> Option<foo>
pub fn render_project_host(&self, cx: &mut ViewContext<Self>) -> Option<impl Element> {
let host = self.project.read(cx).host()?;
let host = self.user_store.read(cx).get_cached_user(host.user_id)?;
let participant_index = self
.user_store
.read(cx)
.participant_indices()
.get(&host.id)?;
Some(
div().border().border_color(gpui::red()).child(
Button::new("project_owner_trigger", host.github_login.clone())
.color(Color::Player(participant_index.0))
.style(ButtonStyle::Subtle)
.label_size(LabelSize::Small)
.tooltip(move |cx| Tooltip::text("Toggle following", cx)),
),
)
}
pub fn render_project_name(&self, cx: &mut ViewContext<Self>) -> impl Element {
let name = {
let mut names = self.project.read(cx).visible_worktrees(cx).map(|worktree| {
let worktree = worktree.read(cx);
worktree.root_name()
});
names.next().unwrap_or("")
};
let name = util::truncate_and_trailoff(name, MAX_PROJECT_NAME_LENGTH);
let workspace = self.workspace.clone();
popover_menu("project_name_trigger")
.trigger(
Button::new("project_name_trigger", name)
.style(ButtonStyle::Subtle)
.label_size(LabelSize::Small)
.tooltip(move |cx| Tooltip::text("Recent Projects", cx)),
)
.menu(move |cx| Some(Self::render_project_popover(workspace.clone(), cx)))
}
pub fn render_project_branch(&self, cx: &mut ViewContext<Self>) -> Option<impl Element> {
let entry = {
let mut names_and_branches =
self.project.read(cx).visible_worktrees(cx).map(|worktree| {
let worktree = worktree.read(cx);
worktree.root_git_entry()
});
names_and_branches.next().flatten()
};
let workspace = self.workspace.upgrade()?;
let branch_name = entry
.as_ref()
.and_then(RepositoryEntry::branch)
.map(|branch| util::truncate_and_trailoff(&branch, MAX_BRANCH_NAME_LENGTH))?;
Some(
popover_menu("project_branch_trigger")
.trigger(
Button::new("project_branch_trigger", branch_name)
.color(Color::Muted)
.style(ButtonStyle::Subtle)
.label_size(LabelSize::Small)
.tooltip(move |cx| {
Tooltip::with_meta(
"Recent Branches",
Some(&ToggleVcsMenu),
"Local branches only",
cx,
)
}),
)
.menu(move |cx| Self::render_vcs_popover(workspace.clone(), cx)),
)
}
fn render_collaborator(
&self,
user: &Arc<User>,
peer_id: PeerId,
is_present: bool,
is_speaking: bool,
is_muted: bool,
room: &Room,
project_id: Option<u64>,
current_user: &Arc<User>,
) -> Option<FacePile> {
let followers = project_id.map_or(&[] as &[_], |id| room.followers_for(peer_id, id));
let pile = FacePile::default()
.child(
Avatar::new(user.avatar_uri.clone())
.grayscale(!is_present)
.border_color(if is_speaking {
gpui::blue()
} else if is_muted {
gpui::red()
} else {
Hsla::default()
}),
)
.children(followers.iter().filter_map(|follower_peer_id| {
let follower = room
.remote_participants()
.values()
.find_map(|p| (p.peer_id == *follower_peer_id).then_some(&p.user))
.or_else(|| {
(self.client.peer_id() == Some(*follower_peer_id)).then_some(current_user)
})?
.clone();
Some(Avatar::new(follower.avatar_uri.clone()))
}));
Some(pile)
}
fn window_activation_changed(&mut self, cx: &mut ViewContext<Self>) {
let project = if cx.is_window_active() {
Some(self.project.clone())
} else {
None
};
ActiveCall::global(cx)
.update(cx, |call, cx| call.set_location(project.as_ref(), cx))
.detach_and_log_err(cx);
}
fn active_call_changed(&mut self, cx: &mut ViewContext<Self>) {
cx.notify();
}
fn share_project(&mut self, _: &ShareProject, cx: &mut ViewContext<Self>) {
let active_call = ActiveCall::global(cx);
let project = self.project.clone();
active_call
.update(cx, |call, cx| call.share_project(project, cx))
.detach_and_log_err(cx);
}
fn unshare_project(&mut self, _: &UnshareProject, cx: &mut ViewContext<Self>) {
let active_call = ActiveCall::global(cx);
let project = self.project.clone();
active_call
.update(cx, |call, cx| call.unshare_project(project, cx))
.log_err();
}
pub fn render_vcs_popover(
workspace: View<Workspace>,
cx: &mut WindowContext<'_>,
) -> Option<View<BranchList>> {
let view = build_branch_list(workspace, cx).log_err()?;
let focus_handle = view.focus_handle(cx);
cx.focus(&focus_handle);
Some(view)
}
pub fn render_project_popover(
workspace: WeakView<Workspace>,
cx: &mut WindowContext<'_>,
) -> View<RecentProjects> {
let view = RecentProjects::open_popover(workspace, cx);
let focus_handle = view.focus_handle(cx);
cx.focus(&focus_handle);
view
}
fn render_connection_status(
&self,
status: &client::Status,
cx: &mut ViewContext<Self>,
) -> Option<AnyElement> {
match status {
client::Status::ConnectionError
| client::Status::ConnectionLost
| client::Status::Reauthenticating { .. }
| client::Status::Reconnecting { .. }
| client::Status::ReconnectionError { .. } => Some(
div()
.id("disconnected")
.bg(gpui::red()) // todo!() @nate
.child(IconElement::new(Icon::Disconnected))
.tooltip(|cx| Tooltip::text("Disconnected", cx))
.into_any_element(),
),
client::Status::UpgradeRequired => {
let auto_updater = auto_update::AutoUpdater::get(cx);
let label = match auto_updater.map(|auto_update| auto_update.read(cx).status()) {
Some(AutoUpdateStatus::Updated) => "Please restart Zed to Collaborate",
Some(AutoUpdateStatus::Installing)
| Some(AutoUpdateStatus::Downloading)
| Some(AutoUpdateStatus::Checking) => "Updating...",
Some(AutoUpdateStatus::Idle) | Some(AutoUpdateStatus::Errored) | None => {
"Please update Zed to Collaborate"
}
};
Some(
div()
.bg(gpui::red()) // todo!() @nate
.child(Button::new("connection-status", label).on_click(|_, cx| {
if let Some(auto_updater) = auto_update::AutoUpdater::get(cx) {
if auto_updater.read(cx).status() == AutoUpdateStatus::Updated {
workspace::restart(&Default::default(), cx);
return;
}
}
auto_update::check(&Default::default(), cx);
}))
.into_any_element(),
)
}
_ => None,
}
}
pub fn render_sign_in_button(&mut self, _: &mut ViewContext<Self>) -> Button {
let client = self.client.clone();
Button::new("sign_in", "Sign in").on_click(move |_, cx| {
let client = client.clone();
cx.spawn(move |mut cx| async move {
client
.authenticate_and_connect(true, &cx)
.await
.notify_async_err(&mut cx);
})
.detach();
})
}
pub fn render_user_menu_button(&mut self, cx: &mut ViewContext<Self>) -> impl Element {
if let Some(user) = self.user_store.read(cx).current_user() {
popover_menu("user-menu")
.menu(|cx| {
ContextMenu::build(cx, |menu, _| {
menu.action("Settings", zed_actions::OpenSettings.boxed_clone())
.action("Theme", theme_selector::Toggle.boxed_clone())
.separator()
.action("Share Feedback", feedback::GiveFeedback.boxed_clone())
.action("Sign Out", client::SignOut.boxed_clone())
})
.into()
})
.trigger(
ButtonLike::new("user-menu")
.child(
h_stack()
.gap_0p5()
.child(Avatar::new(user.avatar_uri.clone()))
.child(IconElement::new(Icon::ChevronDown).color(Color::Muted)),
)
.style(ButtonStyle::Subtle)
.tooltip(move |cx| Tooltip::text("Toggle User Menu", cx)),
)
.anchor(gpui::AnchorCorner::TopRight)
} else {
popover_menu("user-menu")
.menu(|cx| {
ContextMenu::build(cx, |menu, _| {
menu.action("Settings", zed_actions::OpenSettings.boxed_clone())
.action("Theme", theme_selector::Toggle.boxed_clone())
.separator()
.action("Share Feedback", feedback::GiveFeedback.boxed_clone())
})
.into()
})
.trigger(
ButtonLike::new("user-menu")
.child(
h_stack()
.gap_0p5()
.child(IconElement::new(Icon::ChevronDown).color(Color::Muted)),
)
.style(ButtonStyle::Subtle)
.tooltip(move |cx| Tooltip::text("Toggle User Menu", cx)),
)
}
}
}

View file

@ -1,167 +0,0 @@
pub mod channel_view;
pub mod chat_panel;
pub mod collab_panel;
mod collab_titlebar_item;
mod face_pile;
pub mod notification_panel;
pub mod notifications;
mod panel_settings;
use std::{rc::Rc, sync::Arc};
use call::{report_call_event_for_room, ActiveCall, Room};
pub use collab_panel::CollabPanel;
pub use collab_titlebar_item::CollabTitlebarItem;
use feature_flags::{ChannelsAlpha, FeatureFlagAppExt};
use gpui::{
actions, point, AppContext, GlobalPixels, Pixels, PlatformDisplay, Size, Task, WindowBounds,
WindowKind, WindowOptions,
};
pub use panel_settings::{
ChatPanelSettings, CollaborationPanelSettings, NotificationPanelSettings,
};
use settings::Settings;
use util::ResultExt;
use workspace::AppState;
actions!(
collab,
[ToggleScreenSharing, ToggleMute, ToggleDeafen, LeaveCall]
);
pub fn init(app_state: &Arc<AppState>, cx: &mut AppContext) {
CollaborationPanelSettings::register(cx);
ChatPanelSettings::register(cx);
NotificationPanelSettings::register(cx);
vcs_menu::init(cx);
collab_titlebar_item::init(cx);
collab_panel::init(cx);
channel_view::init(cx);
chat_panel::init(cx);
notification_panel::init(cx);
notifications::init(&app_state, cx);
// cx.add_global_action(toggle_screen_sharing);
// cx.add_global_action(toggle_mute);
// cx.add_global_action(toggle_deafen);
}
pub fn toggle_screen_sharing(_: &ToggleScreenSharing, cx: &mut AppContext) {
let call = ActiveCall::global(cx).read(cx);
if let Some(room) = call.room().cloned() {
let client = call.client();
let toggle_screen_sharing = room.update(cx, |room, cx| {
if room.is_screen_sharing() {
report_call_event_for_room(
"disable screen share",
room.id(),
room.channel_id(),
&client,
cx,
);
Task::ready(room.unshare_screen(cx))
} else {
report_call_event_for_room(
"enable screen share",
room.id(),
room.channel_id(),
&client,
cx,
);
room.share_screen(cx)
}
});
toggle_screen_sharing.detach_and_log_err(cx);
}
}
pub fn toggle_mute(_: &ToggleMute, cx: &mut AppContext) {
let call = ActiveCall::global(cx).read(cx);
if let Some(room) = call.room().cloned() {
let client = call.client();
room.update(cx, |room, cx| {
let operation = if room.is_muted(cx) {
"enable microphone"
} else {
"disable microphone"
};
report_call_event_for_room(operation, room.id(), room.channel_id(), &client, cx);
room.toggle_mute(cx)
})
.map(|task| task.detach_and_log_err(cx))
.log_err();
}
}
pub fn toggle_deafen(_: &ToggleDeafen, cx: &mut AppContext) {
if let Some(room) = ActiveCall::global(cx).read(cx).room().cloned() {
room.update(cx, Room::toggle_deafen)
.map(|task| task.detach_and_log_err(cx))
.log_err();
}
}
fn notification_window_options(
screen: Rc<dyn PlatformDisplay>,
window_size: Size<Pixels>,
) -> WindowOptions {
let notification_margin_width = GlobalPixels::from(16.);
let notification_margin_height = GlobalPixels::from(-0.) - GlobalPixels::from(48.);
let screen_bounds = screen.bounds();
let size: Size<GlobalPixels> = window_size.into();
// todo!() use content bounds instead of screen.bounds and get rid of magics in point's 2nd argument.
let bounds = gpui::Bounds::<GlobalPixels> {
origin: screen_bounds.upper_right()
- point(
size.width + notification_margin_width,
notification_margin_height,
),
size: window_size.into(),
};
WindowOptions {
bounds: WindowBounds::Fixed(bounds),
titlebar: None,
center: false,
focus: false,
show: true,
kind: WindowKind::PopUp,
is_movable: false,
display_id: Some(screen.id()),
}
}
// fn render_avatar<T: 'static>(
// avatar: Option<Arc<ImageData>>,
// avatar_style: &AvatarStyle,
// container: ContainerStyle,
// ) -> AnyElement<T> {
// avatar
// .map(|avatar| {
// Image::from_data(avatar)
// .with_style(avatar_style.image)
// .aligned()
// .contained()
// .with_corner_radius(avatar_style.outer_corner_radius)
// .constrained()
// .with_width(avatar_style.outer_width)
// .with_height(avatar_style.outer_width)
// .into_any()
// })
// .unwrap_or_else(|| {
// Empty::new()
// .constrained()
// .with_width(avatar_style.outer_width)
// .into_any()
// })
// .contained()
// .with_style(container)
// .into_any()
// }
fn is_channels_feature_enabled(cx: &gpui::WindowContext<'_>) -> bool {
cx.is_staff() || cx.has_flag::<ChannelsAlpha>()
}

View file

@ -1,30 +0,0 @@
use gpui::{
div, AnyElement, ElementId, IntoElement, ParentElement, RenderOnce, Styled, WindowContext,
};
use smallvec::SmallVec;
#[derive(Default, IntoElement)]
pub struct FacePile {
pub faces: SmallVec<[AnyElement; 2]>,
}
impl RenderOnce for FacePile {
fn render(self, _: &mut WindowContext) -> impl IntoElement {
let player_count = self.faces.len();
let player_list = self.faces.into_iter().enumerate().map(|(ix, player)| {
let isnt_last = ix < player_count - 1;
div()
.z_index((player_count - ix) as u8)
.when(isnt_last, |div| div.neg_mr_1())
.child(player)
});
div().p_1().flex().items_center().children(player_list)
}
}
impl ParentElement for FacePile {
fn children_mut(&mut self) -> &mut SmallVec<[AnyElement; 2]> {
&mut self.faces
}
}

View file

@ -1,755 +0,0 @@
use crate::{chat_panel::ChatPanel, NotificationPanelSettings};
use anyhow::Result;
use channel::ChannelStore;
use client::{Client, Notification, User, UserStore};
use collections::HashMap;
use db::kvp::KEY_VALUE_STORE;
use futures::StreamExt;
use gpui::{
actions, div, img, list, px, serde_json, AnyElement, AppContext, AsyncWindowContext,
CursorStyle, DismissEvent, Element, EventEmitter, FocusHandle, FocusableView,
InteractiveElement, IntoElement, ListAlignment, ListScrollEvent, ListState, Model,
ParentElement, Render, StatefulInteractiveElement, Styled, Task, View, ViewContext,
VisualContext, WeakView, WindowContext,
};
use notifications::{NotificationEntry, NotificationEvent, NotificationStore};
use project::Fs;
use rpc::proto;
use serde::{Deserialize, Serialize};
use settings::{Settings, SettingsStore};
use std::{sync::Arc, time::Duration};
use time::{OffsetDateTime, UtcOffset};
use ui::{h_stack, prelude::*, v_stack, Avatar, Button, Icon, IconButton, IconElement, Label};
use util::{ResultExt, TryFutureExt};
use workspace::{
dock::{DockPosition, Panel, PanelEvent},
Workspace,
};
const LOADING_THRESHOLD: usize = 30;
const MARK_AS_READ_DELAY: Duration = Duration::from_secs(1);
const TOAST_DURATION: Duration = Duration::from_secs(5);
const NOTIFICATION_PANEL_KEY: &'static str = "NotificationPanel";
pub struct NotificationPanel {
client: Arc<Client>,
user_store: Model<UserStore>,
channel_store: Model<ChannelStore>,
notification_store: Model<NotificationStore>,
fs: Arc<dyn Fs>,
width: Option<Pixels>,
active: bool,
notification_list: ListState,
pending_serialization: Task<Option<()>>,
subscriptions: Vec<gpui::Subscription>,
workspace: WeakView<Workspace>,
current_notification_toast: Option<(u64, Task<()>)>,
local_timezone: UtcOffset,
focus_handle: FocusHandle,
mark_as_read_tasks: HashMap<u64, Task<Result<()>>>,
}
#[derive(Serialize, Deserialize)]
struct SerializedNotificationPanel {
width: Option<Pixels>,
}
#[derive(Debug)]
pub enum Event {
DockPositionChanged,
Focus,
Dismissed,
}
pub struct NotificationPresenter {
pub actor: Option<Arc<client::User>>,
pub text: String,
pub icon: &'static str,
pub needs_response: bool,
pub can_navigate: bool,
}
actions!(notification_panel, [ToggleFocus]);
pub fn init(cx: &mut AppContext) {
cx.observe_new_views(|workspace: &mut Workspace, _| {
workspace.register_action(|workspace, _: &ToggleFocus, cx| {
workspace.toggle_panel_focus::<NotificationPanel>(cx);
});
})
.detach();
}
impl NotificationPanel {
pub fn new(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) -> View<Self> {
let fs = workspace.app_state().fs.clone();
let client = workspace.app_state().client.clone();
let user_store = workspace.app_state().user_store.clone();
let workspace_handle = workspace.weak_handle();
cx.new_view(|cx: &mut ViewContext<Self>| {
let mut status = client.status();
cx.spawn(|this, mut cx| async move {
while let Some(_) = status.next().await {
if this
.update(&mut cx, |_, cx| {
cx.notify();
})
.is_err()
{
break;
}
}
})
.detach();
let view = cx.view().downgrade();
let notification_list =
ListState::new(0, ListAlignment::Top, px(1000.), move |ix, cx| {
view.upgrade()
.and_then(|view| {
view.update(cx, |this, cx| this.render_notification(ix, cx))
})
.unwrap_or_else(|| div().into_any())
});
notification_list.set_scroll_handler(cx.listener(
|this, event: &ListScrollEvent, cx| {
if event.count.saturating_sub(event.visible_range.end) < LOADING_THRESHOLD {
if let Some(task) = this
.notification_store
.update(cx, |store, cx| store.load_more_notifications(false, cx))
{
task.detach();
}
}
},
));
let mut this = Self {
fs,
client,
user_store,
local_timezone: cx.local_timezone(),
channel_store: ChannelStore::global(cx),
notification_store: NotificationStore::global(cx),
notification_list,
pending_serialization: Task::ready(None),
workspace: workspace_handle,
focus_handle: cx.focus_handle(),
current_notification_toast: None,
subscriptions: Vec::new(),
active: false,
mark_as_read_tasks: HashMap::default(),
width: None,
};
let mut old_dock_position = this.position(cx);
this.subscriptions.extend([
cx.observe(&this.notification_store, |_, _, cx| cx.notify()),
cx.subscribe(&this.notification_store, Self::on_notification_event),
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(Event::DockPositionChanged);
}
cx.notify();
}),
]);
this
})
}
pub fn load(
workspace: WeakView<Workspace>,
cx: AsyncWindowContext,
) -> Task<Result<View<Self>>> {
cx.spawn(|mut cx| async move {
let serialized_panel = if let Some(panel) = cx
.background_executor()
.spawn(async move { KEY_VALUE_STORE.read_kvp(NOTIFICATION_PANEL_KEY) })
.await
.log_err()
.flatten()
{
Some(serde_json::from_str::<SerializedNotificationPanel>(&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<Self>) {
let width = self.width;
self.pending_serialization = cx.background_executor().spawn(
async move {
KEY_VALUE_STORE
.write_kvp(
NOTIFICATION_PANEL_KEY.into(),
serde_json::to_string(&SerializedNotificationPanel { width })?,
)
.await?;
anyhow::Ok(())
}
.log_err(),
);
}
fn render_notification(&mut self, ix: usize, cx: &mut ViewContext<Self>) -> Option<AnyElement> {
let entry = self.notification_store.read(cx).notification_at(ix)?;
let notification_id = entry.id;
let now = OffsetDateTime::now_utc();
let timestamp = entry.timestamp;
let NotificationPresenter {
actor,
text,
needs_response,
can_navigate,
..
} = self.present_notification(entry, cx)?;
let response = entry.response;
let notification = entry.notification.clone();
if self.active && !entry.is_read {
self.did_render_notification(notification_id, &notification, cx);
}
Some(
div()
.id(ix)
.flex()
.flex_row()
.size_full()
.px_2()
.py_1()
.gap_2()
.when(can_navigate, |el| {
el.cursor(CursorStyle::PointingHand).on_click({
let notification = notification.clone();
cx.listener(move |this, _, cx| {
this.did_click_notification(&notification, cx)
})
})
})
.children(actor.map(|actor| {
img(actor.avatar_uri.clone())
.flex_none()
.w_8()
.h_8()
.rounded_full()
}))
.child(
v_stack()
.gap_1()
.size_full()
.overflow_hidden()
.child(Label::new(text.clone()))
.child(
h_stack()
.child(
Label::new(format_timestamp(
timestamp,
now,
self.local_timezone,
))
.color(Color::Muted),
)
.children(if let Some(is_accepted) = response {
Some(div().flex().flex_grow().justify_end().child(Label::new(
if is_accepted {
"You accepted"
} else {
"You declined"
},
)))
} else if needs_response {
Some(
h_stack()
.flex_grow()
.justify_end()
.child(Button::new("decline", "Decline").on_click({
let notification = notification.clone();
let view = cx.view().clone();
move |_, cx| {
view.update(cx, |this, cx| {
this.respond_to_notification(
notification.clone(),
false,
cx,
)
});
}
}))
.child(Button::new("accept", "Accept").on_click({
let notification = notification.clone();
let view = cx.view().clone();
move |_, cx| {
view.update(cx, |this, cx| {
this.respond_to_notification(
notification.clone(),
true,
cx,
)
});
}
})),
)
} else {
None
}),
),
)
.into_any(),
)
}
fn present_notification(
&self,
entry: &NotificationEntry,
cx: &AppContext,
) -> Option<NotificationPresenter> {
let user_store = self.user_store.read(cx);
let channel_store = self.channel_store.read(cx);
match entry.notification {
Notification::ContactRequest { sender_id } => {
let requester = user_store.get_cached_user(sender_id)?;
Some(NotificationPresenter {
icon: "icons/plus.svg",
text: format!("{} wants to add you as a contact", requester.github_login),
needs_response: user_store.has_incoming_contact_request(requester.id),
actor: Some(requester),
can_navigate: false,
})
}
Notification::ContactRequestAccepted { responder_id } => {
let responder = user_store.get_cached_user(responder_id)?;
Some(NotificationPresenter {
icon: "icons/plus.svg",
text: format!("{} accepted your contact invite", responder.github_login),
needs_response: false,
actor: Some(responder),
can_navigate: false,
})
}
Notification::ChannelInvitation {
ref channel_name,
channel_id,
inviter_id,
} => {
let inviter = user_store.get_cached_user(inviter_id)?;
Some(NotificationPresenter {
icon: "icons/hash.svg",
text: format!(
"{} invited you to join the #{channel_name} channel",
inviter.github_login
),
needs_response: channel_store.has_channel_invitation(channel_id),
actor: Some(inviter),
can_navigate: false,
})
}
Notification::ChannelMessageMention {
sender_id,
channel_id,
message_id,
} => {
let sender = user_store.get_cached_user(sender_id)?;
let channel = channel_store.channel_for_id(channel_id)?;
let message = self
.notification_store
.read(cx)
.channel_message_for_id(message_id)?;
Some(NotificationPresenter {
icon: "icons/conversations.svg",
text: format!(
"{} mentioned you in #{}:\n{}",
sender.github_login, channel.name, message.body,
),
needs_response: false,
actor: Some(sender),
can_navigate: true,
})
}
}
}
fn did_render_notification(
&mut self,
notification_id: u64,
notification: &Notification,
cx: &mut ViewContext<Self>,
) {
let should_mark_as_read = match notification {
Notification::ContactRequestAccepted { .. } => true,
Notification::ContactRequest { .. }
| Notification::ChannelInvitation { .. }
| Notification::ChannelMessageMention { .. } => false,
};
if should_mark_as_read {
self.mark_as_read_tasks
.entry(notification_id)
.or_insert_with(|| {
let client = self.client.clone();
cx.spawn(|this, mut cx| async move {
cx.background_executor().timer(MARK_AS_READ_DELAY).await;
client
.request(proto::MarkNotificationRead { notification_id })
.await?;
this.update(&mut cx, |this, _| {
this.mark_as_read_tasks.remove(&notification_id);
})?;
Ok(())
})
});
}
}
fn did_click_notification(&mut self, notification: &Notification, cx: &mut ViewContext<Self>) {
if let Notification::ChannelMessageMention {
message_id,
channel_id,
..
} = notification.clone()
{
if let Some(workspace) = self.workspace.upgrade() {
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, Some(message_id), cx)
.detach_and_log_err(cx);
});
}
});
});
}
}
}
fn is_showing_notification(&self, notification: &Notification, cx: &ViewContext<Self>) -> bool {
if let Notification::ChannelMessageMention { channel_id, .. } = &notification {
if let Some(workspace) = self.workspace.upgrade() {
return if let Some(panel) = workspace.read(cx).panel::<ChatPanel>(cx) {
let panel = panel.read(cx);
panel.is_scrolled_to_bottom()
&& panel
.active_chat()
.map_or(false, |chat| chat.read(cx).channel_id == *channel_id)
} else {
false
};
}
}
false
}
fn on_notification_event(
&mut self,
_: Model<NotificationStore>,
event: &NotificationEvent,
cx: &mut ViewContext<Self>,
) {
match event {
NotificationEvent::NewNotification { entry } => self.add_toast(entry, cx),
NotificationEvent::NotificationRemoved { entry }
| NotificationEvent::NotificationRead { entry } => self.remove_toast(entry.id, cx),
NotificationEvent::NotificationsUpdated {
old_range,
new_count,
} => {
self.notification_list.splice(old_range.clone(), *new_count);
cx.notify();
}
}
}
fn add_toast(&mut self, entry: &NotificationEntry, cx: &mut ViewContext<Self>) {
if self.is_showing_notification(&entry.notification, cx) {
return;
}
let Some(NotificationPresenter { actor, text, .. }) = self.present_notification(entry, cx)
else {
return;
};
let notification_id = entry.id;
self.current_notification_toast = Some((
notification_id,
cx.spawn(|this, mut cx| async move {
cx.background_executor().timer(TOAST_DURATION).await;
this.update(&mut cx, |this, cx| this.remove_toast(notification_id, cx))
.ok();
}),
));
self.workspace
.update(cx, |workspace, cx| {
workspace.dismiss_notification::<NotificationToast>(0, cx);
workspace.show_notification(0, cx, |cx| {
let workspace = cx.view().downgrade();
cx.new_view(|_| NotificationToast {
notification_id,
actor,
text,
workspace,
})
})
})
.ok();
}
fn remove_toast(&mut self, notification_id: u64, cx: &mut ViewContext<Self>) {
if let Some((current_id, _)) = &self.current_notification_toast {
if *current_id == notification_id {
self.current_notification_toast.take();
self.workspace
.update(cx, |workspace, cx| {
workspace.dismiss_notification::<NotificationToast>(0, cx)
})
.ok();
}
}
}
fn respond_to_notification(
&mut self,
notification: Notification,
response: bool,
cx: &mut ViewContext<Self>,
) {
self.notification_store.update(cx, |store, cx| {
store.respond_to_notification(notification, response, cx);
});
}
}
impl Render for NotificationPanel {
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
v_stack()
.size_full()
.child(
h_stack()
.justify_between()
.px_2()
.py_1()
// Match the height of the tab bar so they line up.
.h(rems(ui::Tab::HEIGHT_IN_REMS))
.border_b_1()
.border_color(cx.theme().colors().border)
.child(Label::new("Notifications"))
.child(IconElement::new(Icon::Envelope)),
)
.map(|this| {
if self.client.user_id().is_none() {
this.child(
v_stack()
.gap_2()
.p_4()
.child(
Button::new("sign_in_prompt_button", "Sign in")
.icon_color(Color::Muted)
.icon(Icon::Github)
.icon_position(IconPosition::Start)
.style(ButtonStyle::Filled)
.full_width()
.on_click({
let client = self.client.clone();
move |_, cx| {
let client = client.clone();
cx.spawn(move |cx| async move {
client
.authenticate_and_connect(true, &cx)
.log_err()
.await;
})
.detach()
}
}),
)
.child(
div().flex().w_full().items_center().child(
Label::new("Sign in to view notifications.")
.color(Color::Muted)
.size(LabelSize::Small),
),
),
)
} else if self.notification_list.item_count() == 0 {
this.child(
v_stack().p_4().child(
div().flex().w_full().items_center().child(
Label::new("You have no notifications.")
.color(Color::Muted)
.size(LabelSize::Small),
),
),
)
} else {
this.child(list(self.notification_list.clone()).size_full())
}
})
}
}
impl FocusableView for NotificationPanel {
fn focus_handle(&self, _: &AppContext) -> FocusHandle {
self.focus_handle.clone()
}
}
impl EventEmitter<Event> for NotificationPanel {}
impl EventEmitter<PanelEvent> for NotificationPanel {}
impl Panel for NotificationPanel {
fn persistent_name() -> &'static str {
"NotificationPanel"
}
fn position(&self, cx: &gpui::WindowContext) -> DockPosition {
NotificationPanelSettings::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::<NotificationPanelSettings>(
self.fs.clone(),
cx,
move |settings| settings.dock = Some(position),
);
}
fn size(&self, cx: &gpui::WindowContext) -> Pixels {
self.width
.unwrap_or_else(|| NotificationPanelSettings::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 set_active(&mut self, active: bool, cx: &mut ViewContext<Self>) {
self.active = active;
if self.notification_store.read(cx).notification_count() == 0 {
cx.emit(Event::Dismissed);
}
}
fn icon(&self, cx: &gpui::WindowContext) -> Option<Icon> {
(NotificationPanelSettings::get_global(cx).button
&& self.notification_store.read(cx).notification_count() > 0)
.then(|| Icon::Bell)
}
fn icon_tooltip(&self, _cx: &WindowContext) -> Option<&'static str> {
Some("Notification Panel")
}
fn icon_label(&self, cx: &WindowContext) -> Option<String> {
let count = self.notification_store.read(cx).unread_notification_count();
if count == 0 {
None
} else {
Some(count.to_string())
}
}
fn toggle_action(&self) -> Box<dyn gpui::Action> {
Box::new(ToggleFocus)
}
}
pub struct NotificationToast {
notification_id: u64,
actor: Option<Arc<User>>,
text: String,
workspace: WeakView<Workspace>,
}
impl NotificationToast {
fn focus_notification_panel(&self, cx: &mut ViewContext<Self>) {
let workspace = self.workspace.clone();
let notification_id = self.notification_id;
cx.window_context().defer(move |cx| {
workspace
.update(cx, |workspace, cx| {
if let Some(panel) = workspace.focus_panel::<NotificationPanel>(cx) {
panel.update(cx, |panel, cx| {
let store = panel.notification_store.read(cx);
if let Some(entry) = store.notification_for_id(notification_id) {
panel.did_click_notification(&entry.clone().notification, cx);
}
});
}
})
.ok();
})
}
}
impl Render for NotificationToast {
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
let user = self.actor.clone();
h_stack()
.id("notification_panel_toast")
.children(user.map(|user| Avatar::new(user.avatar_uri.clone())))
.child(Label::new(self.text.clone()))
.child(
IconButton::new("close", Icon::Close)
.on_click(cx.listener(|_, _, cx| cx.emit(DismissEvent))),
)
.on_click(cx.listener(|this, _, cx| {
this.focus_notification_panel(cx);
cx.emit(DismissEvent);
}))
}
}
impl EventEmitter<DismissEvent> for NotificationToast {}
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();
if date == today {
let difference = now - timestamp;
if difference >= Duration::from_secs(3600) {
format!("{}h", difference.whole_seconds() / 3600)
} else if difference >= Duration::from_secs(60) {
format!("{}m", difference.whole_seconds() / 60)
} else {
"just now".to_string()
}
} else if date.next_day() == Some(today) {
format!("yesterday")
} else {
format!("{:02}/{}/{}", date.month() as u32, date.day(), date.year())
}
}

View file

@ -1,11 +0,0 @@
use gpui::AppContext;
use std::sync::Arc;
use workspace::AppState;
pub mod incoming_call_notification;
pub mod project_shared_notification;
pub fn init(app_state: &Arc<AppState>, cx: &mut AppContext) {
incoming_call_notification::init(app_state, cx);
project_shared_notification::init(app_state, cx);
}

View file

@ -1,163 +0,0 @@
use crate::notification_window_options;
use call::{ActiveCall, IncomingCall};
use futures::StreamExt;
use gpui::{
img, px, AppContext, ParentElement, Render, RenderOnce, Styled, ViewContext,
VisualContext as _, WindowHandle,
};
use settings::Settings;
use std::sync::{Arc, Weak};
use theme::ThemeSettings;
use ui::prelude::*;
use ui::{h_stack, v_stack, Button, Label};
use util::ResultExt;
use workspace::AppState;
pub fn init(app_state: &Arc<AppState>, cx: &mut AppContext) {
let app_state = Arc::downgrade(app_state);
let mut incoming_call = ActiveCall::global(cx).read(cx).incoming();
cx.spawn(|mut cx| async move {
let mut notification_windows: Vec<WindowHandle<IncomingCallNotification>> = Vec::new();
while let Some(incoming_call) = incoming_call.next().await {
for window in notification_windows.drain(..) {
window
.update(&mut cx, |_, cx| {
// todo!()
cx.remove_window();
})
.log_err();
}
if let Some(incoming_call) = incoming_call {
let unique_screens = cx.update(|cx| cx.displays()).unwrap();
let window_size = gpui::Size {
width: px(380.),
height: px(64.),
};
for screen in unique_screens {
let options = notification_window_options(screen, window_size);
let window = cx
.open_window(options, |cx| {
cx.new_view(|_| {
IncomingCallNotification::new(
incoming_call.clone(),
app_state.clone(),
)
})
})
.unwrap();
notification_windows.push(window);
}
}
}
})
.detach();
}
#[derive(Clone, PartialEq)]
struct RespondToCall {
accept: bool,
}
struct IncomingCallNotificationState {
call: IncomingCall,
app_state: Weak<AppState>,
}
pub struct IncomingCallNotification {
state: Arc<IncomingCallNotificationState>,
}
impl IncomingCallNotificationState {
pub fn new(call: IncomingCall, app_state: Weak<AppState>) -> Self {
Self { call, app_state }
}
fn respond(&self, accept: bool, cx: &mut AppContext) {
let active_call = ActiveCall::global(cx);
if accept {
let join = active_call.update(cx, |active_call, cx| active_call.accept_incoming(cx));
let caller_user_id = self.call.calling_user.id;
let initial_project_id = self.call.initial_project.as_ref().map(|project| project.id);
let app_state = self.app_state.clone();
let cx: &mut AppContext = cx;
cx.spawn(|cx| async move {
join.await?;
if let Some(project_id) = initial_project_id {
cx.update(|cx| {
if let Some(app_state) = app_state.upgrade() {
workspace::join_remote_project(
project_id,
caller_user_id,
app_state,
cx,
)
.detach_and_log_err(cx);
}
})
.log_err();
}
anyhow::Ok(())
})
.detach_and_log_err(cx);
} else {
active_call.update(cx, |active_call, cx| {
active_call.decline_incoming(cx).log_err();
});
}
}
}
impl IncomingCallNotification {
pub fn new(call: IncomingCall, app_state: Weak<AppState>) -> Self {
Self {
state: Arc::new(IncomingCallNotificationState::new(call, app_state)),
}
}
}
impl Render for IncomingCallNotification {
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
// TODO: Is there a better place for us to initialize the font?
let (ui_font, ui_font_size) = {
let theme_settings = ThemeSettings::get_global(cx);
(
theme_settings.ui_font.family.clone(),
theme_settings.ui_font_size.clone(),
)
};
cx.set_rem_size(ui_font_size);
h_stack()
.font(ui_font)
.text_ui()
.justify_between()
.size_full()
.overflow_hidden()
.elevation_3(cx)
.p_2()
.gap_2()
.child(
img(self.state.call.calling_user.avatar_uri.clone())
.w_12()
.h_12()
.rounded_full(),
)
.child(v_stack().overflow_hidden().child(Label::new(format!(
"{} is sharing a project in Zed",
self.state.call.calling_user.github_login
))))
.child(
v_stack()
.child(Button::new("accept", "Accept").render(cx).on_click({
let state = self.state.clone();
move |_, cx| state.respond(true, cx)
}))
.child(Button::new("decline", "Decline").render(cx).on_click({
let state = self.state.clone();
move |_, cx| state.respond(false, cx)
})),
)
}
}

View file

@ -1,180 +0,0 @@
use crate::notification_window_options;
use call::{room, ActiveCall};
use client::User;
use collections::HashMap;
use gpui::{img, px, AppContext, ParentElement, Render, Size, Styled, ViewContext, VisualContext};
use settings::Settings;
use std::sync::{Arc, Weak};
use theme::ThemeSettings;
use ui::{h_stack, prelude::*, v_stack, Button, Label};
use workspace::AppState;
pub fn init(app_state: &Arc<AppState>, cx: &mut AppContext) {
let app_state = Arc::downgrade(app_state);
let active_call = ActiveCall::global(cx);
let mut notification_windows = HashMap::default();
cx.subscribe(&active_call, move |_, event, cx| match event {
room::Event::RemoteProjectShared {
owner,
project_id,
worktree_root_names,
} => {
let window_size = Size {
width: px(400.),
height: px(72.),
};
for screen in cx.displays() {
let options = notification_window_options(screen, window_size);
let window = cx.open_window(options, |cx| {
cx.new_view(|_| {
ProjectSharedNotification::new(
owner.clone(),
*project_id,
worktree_root_names.clone(),
app_state.clone(),
)
})
});
notification_windows
.entry(*project_id)
.or_insert(Vec::new())
.push(window);
}
}
room::Event::RemoteProjectUnshared { project_id }
| room::Event::RemoteProjectJoined { project_id }
| room::Event::RemoteProjectInvitationDiscarded { project_id } => {
if let Some(windows) = notification_windows.remove(&project_id) {
for window in windows {
window
.update(cx, |_, cx| {
// todo!()
cx.remove_window();
})
.ok();
}
}
}
room::Event::Left => {
for (_, windows) in notification_windows.drain() {
for window in windows {
window
.update(cx, |_, cx| {
// todo!()
cx.remove_window();
})
.ok();
}
}
}
_ => {}
})
.detach();
}
pub struct ProjectSharedNotification {
project_id: u64,
worktree_root_names: Vec<String>,
owner: Arc<User>,
app_state: Weak<AppState>,
}
impl ProjectSharedNotification {
fn new(
owner: Arc<User>,
project_id: u64,
worktree_root_names: Vec<String>,
app_state: Weak<AppState>,
) -> Self {
Self {
project_id,
worktree_root_names,
owner,
app_state,
}
}
fn join(&mut self, cx: &mut ViewContext<Self>) {
if let Some(app_state) = self.app_state.upgrade() {
workspace::join_remote_project(self.project_id, self.owner.id, app_state, cx)
.detach_and_log_err(cx);
}
}
fn dismiss(&mut self, cx: &mut ViewContext<Self>) {
if let Some(active_room) =
ActiveCall::global(cx).read_with(cx, |call, _| call.room().cloned())
{
active_room.update(cx, |_, cx| {
cx.emit(room::Event::RemoteProjectInvitationDiscarded {
project_id: self.project_id,
});
});
}
}
}
impl Render for ProjectSharedNotification {
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
// TODO: Is there a better place for us to initialize the font?
let (ui_font, ui_font_size) = {
let theme_settings = ThemeSettings::get_global(cx);
(
theme_settings.ui_font.family.clone(),
theme_settings.ui_font_size.clone(),
)
};
cx.set_rem_size(ui_font_size);
h_stack()
.font(ui_font)
.text_ui()
.justify_between()
.size_full()
.overflow_hidden()
.elevation_3(cx)
.p_2()
.gap_2()
.child(
img(self.owner.avatar_uri.clone())
.w_12()
.h_12()
.rounded_full(),
)
.child(
v_stack()
.overflow_hidden()
.child(Label::new(self.owner.github_login.clone()))
.child(Label::new(format!(
"is sharing a project in Zed{}",
if self.worktree_root_names.is_empty() {
""
} else {
":"
}
)))
.children(if self.worktree_root_names.is_empty() {
None
} else {
Some(Label::new(self.worktree_root_names.join(", ")))
}),
)
.child(
v_stack()
.child(Button::new("open", "Open").on_click(cx.listener(
move |this, _event, cx| {
this.join(cx);
},
)))
.child(Button::new("dismiss", "Dismiss").on_click(cx.listener(
move |this, _event, cx| {
this.dismiss(cx);
},
))),
)
}
}

View file

@ -1,70 +0,0 @@
use anyhow;
use gpui::Pixels;
use schemars::JsonSchema;
use serde_derive::{Deserialize, Serialize};
use settings::Settings;
use workspace::dock::DockPosition;
#[derive(Deserialize, Debug)]
pub struct CollaborationPanelSettings {
pub button: bool,
pub dock: DockPosition,
pub default_width: Pixels,
}
#[derive(Deserialize, Debug)]
pub struct ChatPanelSettings {
pub button: bool,
pub dock: DockPosition,
pub default_width: Pixels,
}
#[derive(Deserialize, Debug)]
pub struct NotificationPanelSettings {
pub button: bool,
pub dock: DockPosition,
pub default_width: Pixels,
}
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)]
pub struct PanelSettingsContent {
pub button: Option<bool>,
pub dock: Option<DockPosition>,
pub default_width: Option<f32>,
}
impl Settings for CollaborationPanelSettings {
const KEY: Option<&'static str> = Some("collaboration_panel");
type FileContent = PanelSettingsContent;
fn load(
default_value: &Self::FileContent,
user_values: &[&Self::FileContent],
_: &mut gpui::AppContext,
) -> anyhow::Result<Self> {
Self::load_via_json_merge(default_value, user_values)
}
}
impl Settings for ChatPanelSettings {
const KEY: Option<&'static str> = Some("chat_panel");
type FileContent = PanelSettingsContent;
fn load(
default_value: &Self::FileContent,
user_values: &[&Self::FileContent],
_: &mut gpui::AppContext,
) -> anyhow::Result<Self> {
Self::load_via_json_merge(default_value, user_values)
}
}
impl Settings for NotificationPanelSettings {
const KEY: Option<&'static str> = Some("notification_panel");
type FileContent = PanelSettingsContent;
fn load(
default_value: &Self::FileContent,
user_values: &[&Self::FileContent],
_: &mut gpui::AppContext,
) -> anyhow::Result<Self> {
Self::load_via_json_merge(default_value, user_values)
}
}

View file

@ -9,15 +9,14 @@ path = "src/quick_action_bar.rs"
doctest = false doctest = false
[dependencies] [dependencies]
assistant = { path = "../assistant" } assistant = { package = "assistant2", path = "../assistant2" }
editor = { path = "../editor" } editor = { package = "editor2", path = "../editor2" }
gpui = { path = "../gpui" } gpui = { package = "gpui2", path = "../gpui2" }
search = { path = "../search" } search = { package = "search2", path = "../search2" }
theme = { path = "../theme" } workspace = { package = "workspace2", path = "../workspace2" }
workspace = { path = "../workspace" } ui = { package = "ui2", path = "../ui2" }
[dev-dependencies] [dev-dependencies]
editor = { path = "../editor", features = ["test-support"] } editor = { package = "editor2", path = "../editor2", features = ["test-support"] }
gpui = { path = "../gpui", features = ["test-support"] } gpui = { package = "gpui2", path = "../gpui2", features = ["test-support"] }
theme = { path = "../theme", features = ["test-support"] } workspace = { package = "workspace2", path = "../workspace2", features = ["test-support"] }
workspace = { path = "../workspace", features = ["test-support"] }

View file

@ -1,155 +1,153 @@
use assistant::{assistant_panel::InlineAssist, AssistantPanel}; use assistant::{AssistantPanel, InlineAssist};
use editor::Editor; use editor::Editor;
use gpui::{ use gpui::{
elements::{Empty, Flex, MouseEventHandler, ParentElement, Svg}, Action, ClickEvent, ElementId, EventEmitter, InteractiveElement, ParentElement, Render, Styled,
platform::{CursorStyle, MouseButton}, Subscription, View, ViewContext, WeakView,
Action, AnyElement, Element, Entity, EventContext, Subscription, View, ViewContext, ViewHandle, };
WeakViewHandle, use search::{buffer_search, BufferSearchBar};
use ui::{prelude::*, ButtonSize, ButtonStyle, Icon, IconButton, IconSize, Tooltip};
use workspace::{
item::ItemHandle, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, Workspace,
}; };
use search::{buffer_search, BufferSearchBar};
use workspace::{item::ItemHandle, ToolbarItemLocation, ToolbarItemView, Workspace};
pub struct QuickActionBar { pub struct QuickActionBar {
buffer_search_bar: ViewHandle<BufferSearchBar>, buffer_search_bar: View<BufferSearchBar>,
active_item: Option<Box<dyn ItemHandle>>, active_item: Option<Box<dyn ItemHandle>>,
inlay_hints_enabled_subscription: Option<Subscription>, _inlay_hints_enabled_subscription: Option<Subscription>,
workspace: WeakViewHandle<Workspace>, workspace: WeakView<Workspace>,
} }
impl QuickActionBar { impl QuickActionBar {
pub fn new(buffer_search_bar: ViewHandle<BufferSearchBar>, workspace: &Workspace) -> Self { pub fn new(buffer_search_bar: View<BufferSearchBar>, workspace: &Workspace) -> Self {
Self { Self {
buffer_search_bar, buffer_search_bar,
active_item: None, active_item: None,
inlay_hints_enabled_subscription: None, _inlay_hints_enabled_subscription: None,
workspace: workspace.weak_handle(), workspace: workspace.weak_handle(),
} }
} }
fn active_editor(&self) -> Option<ViewHandle<Editor>> { fn active_editor(&self) -> Option<View<Editor>> {
self.active_item self.active_item
.as_ref() .as_ref()
.and_then(|item| item.downcast::<Editor>()) .and_then(|item| item.downcast::<Editor>())
} }
} }
impl Entity for QuickActionBar { impl Render for QuickActionBar {
type Event = (); fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
}
impl View for QuickActionBar {
fn ui_name() -> &'static str {
"QuickActionsBar"
}
fn render(&mut self, cx: &mut gpui::ViewContext<'_, '_, Self>) -> gpui::AnyElement<Self> {
let Some(editor) = self.active_editor() else { let Some(editor) = self.active_editor() else {
return Empty::new().into_any(); return div().id("empty quick action bar");
}; };
let mut bar = Flex::row(); let inlay_hints_button = Some(QuickActionBarButton::new(
if editor.read(cx).supports_inlay_hints(cx) { "toggle inlay hints",
bar = bar.with_child(render_quick_action_bar_button( Icon::InlayHint,
0, editor.read(cx).inlay_hints_enabled(),
"icons/inlay_hint.svg", Box::new(editor::ToggleInlayHints),
editor.read(cx).inlay_hints_enabled(), "Toggle Inlay Hints",
( {
"Toggle Inlay Hints".to_string(), let editor = editor.clone();
Some(Box::new(editor::ToggleInlayHints)), move |_, cx| {
), editor.update(cx, |editor, cx| {
cx, editor.toggle_inlay_hints(&editor::ToggleInlayHints, cx);
|this, cx| {
if let Some(editor) = this.active_editor() {
editor.update(cx, |editor, cx| {
editor.toggle_inlay_hints(&editor::ToggleInlayHints, cx);
});
}
},
));
}
if editor.read(cx).buffer().read(cx).is_singleton() {
let search_bar_shown = !self.buffer_search_bar.read(cx).is_dismissed();
let search_action = buffer_search::Deploy { focus: true };
bar = bar.with_child(render_quick_action_bar_button(
1,
"icons/magnifying_glass.svg",
search_bar_shown,
(
"Buffer Search".to_string(),
Some(Box::new(search_action.clone())),
),
cx,
move |this, cx| {
this.buffer_search_bar.update(cx, |buffer_search_bar, cx| {
if search_bar_shown {
buffer_search_bar.dismiss(&buffer_search::Dismiss, cx);
} else {
buffer_search_bar.deploy(&search_action, cx);
}
});
},
));
}
bar.add_child(render_quick_action_bar_button(
2,
"icons/magic-wand.svg",
false,
("Inline Assist".into(), Some(Box::new(InlineAssist))),
cx,
move |this, cx| {
if let Some(workspace) = this.workspace.upgrade(cx) {
workspace.update(cx, |workspace, cx| {
AssistantPanel::inline_assist(workspace, &Default::default(), cx);
}); });
} }
}, },
)); ))
.filter(|_| editor.read(cx).supports_inlay_hints(cx));
bar.into_any() let search_button = Some(QuickActionBarButton::new(
"toggle buffer search",
Icon::MagnifyingGlass,
!self.buffer_search_bar.read(cx).is_dismissed(),
Box::new(buffer_search::Deploy { focus: false }),
"Buffer Search",
{
let buffer_search_bar = self.buffer_search_bar.clone();
move |_, cx| {
buffer_search_bar.update(cx, |search_bar, cx| {
search_bar.toggle(&buffer_search::Deploy { focus: true }, cx)
});
}
},
))
.filter(|_| editor.is_singleton(cx));
let assistant_button = QuickActionBarButton::new(
"toggle inline assistant",
Icon::MagicWand,
false,
Box::new(InlineAssist),
"Inline Assist",
{
let workspace = self.workspace.clone();
move |_, cx| {
if let Some(workspace) = workspace.upgrade() {
workspace.update(cx, |workspace, cx| {
AssistantPanel::inline_assist(workspace, &InlineAssist, cx);
});
}
}
},
);
h_stack()
.id("quick action bar")
.p_1()
.gap_2()
.children(inlay_hints_button)
.children(search_button)
.child(assistant_button)
} }
} }
fn render_quick_action_bar_button< impl EventEmitter<ToolbarItemEvent> for QuickActionBar {}
F: 'static + Fn(&mut QuickActionBar, &mut EventContext<QuickActionBar>),
>( #[derive(IntoElement)]
index: usize, struct QuickActionBarButton {
icon: &'static str, id: ElementId,
icon: Icon,
toggled: bool, toggled: bool,
tooltip: (String, Option<Box<dyn Action>>), action: Box<dyn Action>,
cx: &mut ViewContext<QuickActionBar>, tooltip: SharedString,
on_click: F, on_click: Box<dyn Fn(&ClickEvent, &mut WindowContext)>,
) -> AnyElement<QuickActionBar> { }
enum QuickActionBarButton {}
let theme = theme::current(cx); impl QuickActionBarButton {
let (tooltip_text, action) = tooltip; fn new(
id: impl Into<ElementId>,
icon: Icon,
toggled: bool,
action: Box<dyn Action>,
tooltip: impl Into<SharedString>,
on_click: impl Fn(&ClickEvent, &mut WindowContext) + 'static,
) -> Self {
Self {
id: id.into(),
icon,
toggled,
action,
tooltip: tooltip.into(),
on_click: Box::new(on_click),
}
}
}
MouseEventHandler::new::<QuickActionBarButton, _>(index, cx, |mouse_state, _| { impl RenderOnce for QuickActionBarButton {
let style = theme fn render(self, _: &mut WindowContext) -> impl IntoElement {
.workspace let tooltip = self.tooltip.clone();
.toolbar let action = self.action.boxed_clone();
.toggleable_tool
.in_state(toggled) IconButton::new(self.id.clone(), self.icon)
.style_for(mouse_state); .size(ButtonSize::Compact)
Svg::new(icon) .icon_size(IconSize::Small)
.with_color(style.color) .style(ButtonStyle::Subtle)
.constrained() .selected(self.toggled)
.with_width(style.icon_width) .tooltip(move |cx| Tooltip::for_action(tooltip.clone(), &*action, cx))
.aligned() .on_click(move |event, cx| (self.on_click)(event, cx))
.constrained() }
.with_width(style.button_width)
.with_height(style.button_width)
.contained()
.with_style(style.container)
})
.with_cursor_style(CursorStyle::PointingHand)
.on_click(MouseButton::Left, move |_, pane, cx| on_click(pane, cx))
.with_tooltip::<QuickActionBarButton>(index, tooltip_text, action, theme.tooltip.clone(), cx)
.into_any_named("quick action bar button")
} }
impl ToolbarItemView for QuickActionBar { impl ToolbarItemView for QuickActionBar {
@ -161,12 +159,12 @@ impl ToolbarItemView for QuickActionBar {
match active_pane_item { match active_pane_item {
Some(active_item) => { Some(active_item) => {
self.active_item = Some(active_item.boxed_clone()); self.active_item = Some(active_item.boxed_clone());
self.inlay_hints_enabled_subscription.take(); self._inlay_hints_enabled_subscription.take();
if let Some(editor) = active_item.downcast::<Editor>() { if let Some(editor) = active_item.downcast::<Editor>() {
let mut inlay_hints_enabled = editor.read(cx).inlay_hints_enabled(); let mut inlay_hints_enabled = editor.read(cx).inlay_hints_enabled();
let mut supports_inlay_hints = editor.read(cx).supports_inlay_hints(cx); let mut supports_inlay_hints = editor.read(cx).supports_inlay_hints(cx);
self.inlay_hints_enabled_subscription = self._inlay_hints_enabled_subscription =
Some(cx.observe(&editor, move |_, editor, cx| { Some(cx.observe(&editor, move |_, editor, cx| {
let editor = editor.read(cx); let editor = editor.read(cx);
let new_inlay_hints_enabled = editor.inlay_hints_enabled(); let new_inlay_hints_enabled = editor.inlay_hints_enabled();
@ -179,7 +177,7 @@ impl ToolbarItemView for QuickActionBar {
cx.notify() cx.notify()
} }
})); }));
ToolbarItemLocation::PrimaryRight { flex: None } ToolbarItemLocation::PrimaryRight
} else { } else {
ToolbarItemLocation::Hidden ToolbarItemLocation::Hidden
} }

View file

@ -1,22 +0,0 @@
[package]
name = "quick_action_bar2"
version = "0.1.0"
edition = "2021"
publish = false
[lib]
path = "src/quick_action_bar.rs"
doctest = false
[dependencies]
assistant = { package = "assistant2", path = "../assistant2" }
editor = { package = "editor2", path = "../editor2" }
gpui = { package = "gpui2", path = "../gpui2" }
search = { package = "search2", path = "../search2" }
workspace = { package = "workspace2", path = "../workspace2" }
ui = { package = "ui2", path = "../ui2" }
[dev-dependencies]
editor = { package = "editor2", path = "../editor2", features = ["test-support"] }
gpui = { package = "gpui2", path = "../gpui2", features = ["test-support"] }
workspace = { package = "workspace2", path = "../workspace2", features = ["test-support"] }

View file

@ -1,191 +0,0 @@
use assistant::{AssistantPanel, InlineAssist};
use editor::Editor;
use gpui::{
Action, ClickEvent, ElementId, EventEmitter, InteractiveElement, ParentElement, Render, Styled,
Subscription, View, ViewContext, WeakView,
};
use search::{buffer_search, BufferSearchBar};
use ui::{prelude::*, ButtonSize, ButtonStyle, Icon, IconButton, IconSize, Tooltip};
use workspace::{
item::ItemHandle, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, Workspace,
};
pub struct QuickActionBar {
buffer_search_bar: View<BufferSearchBar>,
active_item: Option<Box<dyn ItemHandle>>,
_inlay_hints_enabled_subscription: Option<Subscription>,
workspace: WeakView<Workspace>,
}
impl QuickActionBar {
pub fn new(buffer_search_bar: View<BufferSearchBar>, workspace: &Workspace) -> Self {
Self {
buffer_search_bar,
active_item: None,
_inlay_hints_enabled_subscription: None,
workspace: workspace.weak_handle(),
}
}
fn active_editor(&self) -> Option<View<Editor>> {
self.active_item
.as_ref()
.and_then(|item| item.downcast::<Editor>())
}
}
impl Render for QuickActionBar {
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
let Some(editor) = self.active_editor() else {
return div().id("empty quick action bar");
};
let inlay_hints_button = Some(QuickActionBarButton::new(
"toggle inlay hints",
Icon::InlayHint,
editor.read(cx).inlay_hints_enabled(),
Box::new(editor::ToggleInlayHints),
"Toggle Inlay Hints",
{
let editor = editor.clone();
move |_, cx| {
editor.update(cx, |editor, cx| {
editor.toggle_inlay_hints(&editor::ToggleInlayHints, cx);
});
}
},
))
.filter(|_| editor.read(cx).supports_inlay_hints(cx));
let search_button = Some(QuickActionBarButton::new(
"toggle buffer search",
Icon::MagnifyingGlass,
!self.buffer_search_bar.read(cx).is_dismissed(),
Box::new(buffer_search::Deploy { focus: false }),
"Buffer Search",
{
let buffer_search_bar = self.buffer_search_bar.clone();
move |_, cx| {
buffer_search_bar.update(cx, |search_bar, cx| {
search_bar.toggle(&buffer_search::Deploy { focus: true }, cx)
});
}
},
))
.filter(|_| editor.is_singleton(cx));
let assistant_button = QuickActionBarButton::new(
"toggle inline assistant",
Icon::MagicWand,
false,
Box::new(InlineAssist),
"Inline Assist",
{
let workspace = self.workspace.clone();
move |_, cx| {
if let Some(workspace) = workspace.upgrade() {
workspace.update(cx, |workspace, cx| {
AssistantPanel::inline_assist(workspace, &InlineAssist, cx);
});
}
}
},
);
h_stack()
.id("quick action bar")
.p_1()
.gap_2()
.children(inlay_hints_button)
.children(search_button)
.child(assistant_button)
}
}
impl EventEmitter<ToolbarItemEvent> for QuickActionBar {}
#[derive(IntoElement)]
struct QuickActionBarButton {
id: ElementId,
icon: Icon,
toggled: bool,
action: Box<dyn Action>,
tooltip: SharedString,
on_click: Box<dyn Fn(&ClickEvent, &mut WindowContext)>,
}
impl QuickActionBarButton {
fn new(
id: impl Into<ElementId>,
icon: Icon,
toggled: bool,
action: Box<dyn Action>,
tooltip: impl Into<SharedString>,
on_click: impl Fn(&ClickEvent, &mut WindowContext) + 'static,
) -> Self {
Self {
id: id.into(),
icon,
toggled,
action,
tooltip: tooltip.into(),
on_click: Box::new(on_click),
}
}
}
impl RenderOnce for QuickActionBarButton {
fn render(self, _: &mut WindowContext) -> impl IntoElement {
let tooltip = self.tooltip.clone();
let action = self.action.boxed_clone();
IconButton::new(self.id.clone(), self.icon)
.size(ButtonSize::Compact)
.icon_size(IconSize::Small)
.style(ButtonStyle::Subtle)
.selected(self.toggled)
.tooltip(move |cx| Tooltip::for_action(tooltip.clone(), &*action, cx))
.on_click(move |event, cx| (self.on_click)(event, cx))
}
}
impl ToolbarItemView for QuickActionBar {
fn set_active_pane_item(
&mut self,
active_pane_item: Option<&dyn ItemHandle>,
cx: &mut ViewContext<Self>,
) -> ToolbarItemLocation {
match active_pane_item {
Some(active_item) => {
self.active_item = Some(active_item.boxed_clone());
self._inlay_hints_enabled_subscription.take();
if let Some(editor) = active_item.downcast::<Editor>() {
let mut inlay_hints_enabled = editor.read(cx).inlay_hints_enabled();
let mut supports_inlay_hints = editor.read(cx).supports_inlay_hints(cx);
self._inlay_hints_enabled_subscription =
Some(cx.observe(&editor, move |_, editor, cx| {
let editor = editor.read(cx);
let new_inlay_hints_enabled = editor.inlay_hints_enabled();
let new_supports_inlay_hints = editor.supports_inlay_hints(cx);
let should_notify = inlay_hints_enabled != new_inlay_hints_enabled
|| supports_inlay_hints != new_supports_inlay_hints;
inlay_hints_enabled = new_inlay_hints_enabled;
supports_inlay_hints = new_supports_inlay_hints;
if should_notify {
cx.notify()
}
}));
ToolbarItemLocation::PrimaryRight
} else {
ToolbarItemLocation::Hidden
}
}
None => {
self.active_item = None;
ToolbarItemLocation::Hidden
}
}
}
}

View file

@ -6,12 +6,12 @@ publish = false
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies] [dependencies]
fuzzy = {path = "../fuzzy"} fuzzy = {package = "fuzzy2", path = "../fuzzy2"}
fs = {path = "../fs"} fs = {package = "fs2", path = "../fs2"}
gpui = {path = "../gpui"} gpui = {package = "gpui2", path = "../gpui2"}
picker = {path = "../picker"} picker = {package = "picker2", path = "../picker2"}
util = {path = "../util"} util = {path = "../util"}
theme = {path = "../theme"} ui = {package = "ui2", path = "../ui2"}
workspace = {path = "../workspace"} workspace = {package = "workspace2", path = "../workspace2"}
anyhow.workspace = true anyhow.workspace = true

View file

@ -2,57 +2,95 @@ use anyhow::{anyhow, bail, Result};
use fs::repository::Branch; use fs::repository::Branch;
use fuzzy::{StringMatch, StringMatchCandidate}; use fuzzy::{StringMatch, StringMatchCandidate};
use gpui::{ use gpui::{
actions, actions, rems, AnyElement, AppContext, DismissEvent, Element, EventEmitter, FocusHandle,
elements::*, FocusableView, InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled,
platform::{CursorStyle, MouseButton}, Subscription, Task, View, ViewContext, VisualContext, WindowContext,
AppContext, MouseState, Task, ViewContext, ViewHandle,
}; };
use picker::{Picker, PickerDelegate, PickerEvent}; use picker::{Picker, PickerDelegate};
use std::{ops::Not, sync::Arc}; use std::{ops::Not, sync::Arc};
use ui::{
h_stack, v_stack, Button, ButtonCommon, Clickable, HighlightedLabel, Label, LabelCommon,
LabelSize, ListItem, ListItemSpacing, Selectable,
};
use util::ResultExt; use util::ResultExt;
use workspace::{Toast, Workspace}; use workspace::{ModalView, Toast, Workspace};
actions!(branches, [OpenRecent]); actions!(branches, [OpenRecent]);
pub fn init(cx: &mut AppContext) { pub fn init(cx: &mut AppContext) {
Picker::<BranchListDelegate>::init(cx); // todo!() po
cx.add_action(toggle); cx.observe_new_views(|workspace: &mut Workspace, _| {
workspace.register_action(|workspace, action, cx| {
BranchList::toggle_modal(workspace, action, cx).log_err();
});
})
.detach();
}
pub struct BranchList {
pub picker: View<Picker<BranchListDelegate>>,
rem_width: f32,
_subscription: Subscription,
}
impl BranchList {
fn new(delegate: BranchListDelegate, rem_width: f32, cx: &mut ViewContext<Self>) -> Self {
let picker = cx.new_view(|cx| Picker::new(delegate, cx));
let _subscription = cx.subscribe(&picker, |_, _, _, cx| cx.emit(DismissEvent));
Self {
picker,
rem_width,
_subscription,
}
}
fn toggle_modal(
workspace: &mut Workspace,
_: &OpenRecent,
cx: &mut ViewContext<Workspace>,
) -> Result<()> {
// Modal branch picker has a longer trailoff than a popover one.
let delegate = BranchListDelegate::new(workspace, cx.view().clone(), 70, cx)?;
workspace.toggle_modal(cx, |cx| BranchList::new(delegate, 34., cx));
Ok(())
}
}
impl ModalView for BranchList {}
impl EventEmitter<DismissEvent> for BranchList {}
impl FocusableView for BranchList {
fn focus_handle(&self, cx: &AppContext) -> FocusHandle {
self.picker.focus_handle(cx)
}
}
impl Render for BranchList {
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
v_stack()
.w(rems(self.rem_width))
.child(self.picker.clone())
.on_mouse_down_out(cx.listener(|this, _, cx| {
this.picker.update(cx, |this, cx| {
this.cancel(&Default::default(), cx);
})
}))
}
} }
pub type BranchList = Picker<BranchListDelegate>;
pub fn build_branch_list( pub fn build_branch_list(
workspace: ViewHandle<Workspace>, workspace: View<Workspace>,
cx: &mut ViewContext<BranchList>, cx: &mut WindowContext<'_>,
) -> Result<BranchList> { ) -> Result<View<BranchList>> {
let delegate = workspace.read_with(cx, |workspace, cx| { let delegate = workspace.update(cx, |workspace, cx| {
BranchListDelegate::new(workspace, cx.handle(), 29, cx) BranchListDelegate::new(workspace, cx.view().clone(), 29, cx)
})?; })?;
Ok(cx.new_view(move |cx| BranchList::new(delegate, 20., cx)))
Ok(Picker::new(delegate, cx).with_theme(|theme| theme.picker.clone()))
}
fn toggle(
workspace: &mut Workspace,
_: &OpenRecent,
cx: &mut ViewContext<Workspace>,
) -> Result<()> {
// Modal branch picker has a longer trailoff than a popover one.
let delegate = BranchListDelegate::new(workspace, cx.handle(), 70, cx)?;
workspace.toggle_modal(cx, |_, cx| {
cx.add_view(|cx| {
Picker::new(delegate, cx)
.with_theme(|theme| theme.picker.clone())
.with_max_size(800., 1200.)
})
});
Ok(())
} }
pub struct BranchListDelegate { pub struct BranchListDelegate {
matches: Vec<StringMatch>, matches: Vec<StringMatch>,
all_branches: Vec<Branch>, all_branches: Vec<Branch>,
workspace: ViewHandle<Workspace>, workspace: View<Workspace>,
selected_index: usize, selected_index: usize,
last_query: String, last_query: String,
/// Max length of branch name before we truncate it and add a trailing `...`. /// Max length of branch name before we truncate it and add a trailing `...`.
@ -62,7 +100,7 @@ pub struct BranchListDelegate {
impl BranchListDelegate { impl BranchListDelegate {
fn new( fn new(
workspace: &Workspace, workspace: &Workspace,
handle: ViewHandle<Workspace>, handle: View<Workspace>,
branch_name_trailoff_after: usize, branch_name_trailoff_after: usize,
cx: &AppContext, cx: &AppContext,
) -> Result<Self> { ) -> Result<Self> {
@ -87,7 +125,7 @@ impl BranchListDelegate {
}) })
} }
fn display_error_toast(&self, message: String, cx: &mut ViewContext<BranchList>) { fn display_error_toast(&self, message: String, cx: &mut WindowContext<'_>) {
const GIT_CHECKOUT_FAILURE_ID: usize = 2048; const GIT_CHECKOUT_FAILURE_ID: usize = 2048;
self.workspace.update(cx, |model, ctx| { self.workspace.update(cx, |model, ctx| {
model.show_toast(Toast::new(GIT_CHECKOUT_FAILURE_ID, message), ctx) model.show_toast(Toast::new(GIT_CHECKOUT_FAILURE_ID, message), ctx)
@ -96,6 +134,8 @@ impl BranchListDelegate {
} }
impl PickerDelegate for BranchListDelegate { impl PickerDelegate for BranchListDelegate {
type ListItem = ListItem;
fn placeholder_text(&self) -> Arc<str> { fn placeholder_text(&self) -> Arc<str> {
"Select branch...".into() "Select branch...".into()
} }
@ -114,9 +154,9 @@ impl PickerDelegate for BranchListDelegate {
fn update_matches(&mut self, query: String, cx: &mut ViewContext<Picker<Self>>) -> Task<()> { fn update_matches(&mut self, query: String, cx: &mut ViewContext<Picker<Self>>) -> Task<()> {
cx.spawn(move |picker, mut cx| async move { cx.spawn(move |picker, mut cx| async move {
let candidates = picker.read_with(&mut cx, |view, _| { let candidates = picker.update(&mut cx, |view, _| {
const RECENT_BRANCHES_COUNT: usize = 10; const RECENT_BRANCHES_COUNT: usize = 10;
let mut branches = view.delegate().all_branches.clone(); let mut branches = view.delegate.all_branches.clone();
if query.is_empty() && branches.len() > RECENT_BRANCHES_COUNT { if query.is_empty() && branches.len() > RECENT_BRANCHES_COUNT {
// Truncate list of recent branches // Truncate list of recent branches
// Do a partial sort to show recent-ish branches first. // Do a partial sort to show recent-ish branches first.
@ -157,13 +197,13 @@ impl PickerDelegate for BranchListDelegate {
true, true,
10000, 10000,
&Default::default(), &Default::default(),
cx.background(), cx.background_executor().clone(),
) )
.await .await
}; };
picker picker
.update(&mut cx, |picker, _| { .update(&mut cx, |picker, _| {
let delegate = picker.delegate_mut(); let delegate = &mut picker.delegate;
delegate.matches = matches; delegate.matches = matches;
if delegate.matches.is_empty() { if delegate.matches.is_empty() {
delegate.selected_index = 0; delegate.selected_index = 0;
@ -189,7 +229,7 @@ impl PickerDelegate for BranchListDelegate {
cx.spawn(|picker, mut cx| async move { cx.spawn(|picker, mut cx| async move {
picker picker
.update(&mut cx, |this, cx| { .update(&mut cx, |this, cx| {
let project = this.delegate().workspace.read(cx).project().read(cx); let project = this.delegate.workspace.read(cx).project().read(cx);
let mut cwd = project let mut cwd = project
.visible_worktrees(cx) .visible_worktrees(cx)
.next() .next()
@ -210,10 +250,10 @@ impl PickerDelegate for BranchListDelegate {
.lock() .lock()
.change_branch(&current_pick); .change_branch(&current_pick);
if status.is_err() { if status.is_err() {
this.delegate().display_error_toast(format!("Failed to checkout branch '{current_pick}', check for conflicts or unstashed files"), cx); this.delegate.display_error_toast(format!("Failed to checkout branch '{current_pick}', check for conflicts or unstashed files"), cx);
status?; status?;
} }
cx.emit(PickerEvent::Dismiss); cx.emit(DismissEvent);
Ok::<(), anyhow::Error>(()) Ok::<(), anyhow::Error>(())
}) })
@ -223,123 +263,96 @@ impl PickerDelegate for BranchListDelegate {
} }
fn dismissed(&mut self, cx: &mut ViewContext<Picker<Self>>) { fn dismissed(&mut self, cx: &mut ViewContext<Picker<Self>>) {
cx.emit(PickerEvent::Dismiss); cx.emit(DismissEvent);
} }
fn render_match( fn render_match(
&self, &self,
ix: usize, ix: usize,
mouse_state: &mut MouseState,
selected: bool, selected: bool,
cx: &gpui::AppContext, _cx: &mut ViewContext<Picker<Self>>,
) -> AnyElement<Picker<Self>> { ) -> Option<Self::ListItem> {
let theme = &theme::current(cx);
let hit = &self.matches[ix]; let hit = &self.matches[ix];
let shortened_branch_name = let shortened_branch_name =
util::truncate_and_trailoff(&hit.string, self.branch_name_trailoff_after); util::truncate_and_trailoff(&hit.string, self.branch_name_trailoff_after);
let highlights = hit let highlights: Vec<_> = hit
.positions .positions
.iter() .iter()
.filter(|index| index < &&self.branch_name_trailoff_after)
.copied() .copied()
.filter(|index| index < &self.branch_name_trailoff_after)
.collect(); .collect();
let style = theme.picker.item.in_state(selected).style_for(mouse_state); Some(
Flex::row() ListItem::new(SharedString::from(format!("vcs-menu-{ix}")))
.with_child( .inset(true)
Label::new(shortened_branch_name.clone(), style.label.clone()) .spacing(ListItemSpacing::Sparse)
.with_highlights(highlights) .selected(selected)
.contained() .start_slot(HighlightedLabel::new(shortened_branch_name, highlights)),
.aligned() )
.left(),
)
.contained()
.with_style(style.container)
.constrained()
.with_height(theme.collab_panel.tabbed_modal.row_height)
.into_any()
} }
fn render_header( fn render_header(&self, _: &mut ViewContext<Picker<Self>>) -> Option<AnyElement> {
&self,
cx: &mut ViewContext<Picker<Self>>,
) -> Option<AnyElement<Picker<Self>>> {
let theme = &theme::current(cx);
let style = theme.picker.header.clone();
let label = if self.last_query.is_empty() { let label = if self.last_query.is_empty() {
Flex::row() h_stack()
.with_child(Label::new("Recent branches", style.label.clone())) .ml_3()
.contained() .child(Label::new("Recent branches").size(LabelSize::Small))
.with_style(style.container)
} else { } else {
Flex::row() let match_label = self.matches.is_empty().not().then(|| {
.with_child(Label::new("Branches", style.label.clone())) let suffix = if self.matches.len() == 1 { "" } else { "es" };
.with_children(self.matches.is_empty().not().then(|| { Label::new(format!("{} match{}", self.matches.len(), suffix)).size(LabelSize::Small)
let suffix = if self.matches.len() == 1 { "" } else { "es" }; });
Label::new( h_stack()
format!("{} match{}", self.matches.len(), suffix), .px_3()
style.label, .h_full()
) .justify_between()
.flex_float() .child(Label::new("Branches").size(LabelSize::Small))
})) .children(match_label)
.contained()
.with_style(style.container)
}; };
Some(label.into_any()) Some(label.into_any())
} }
fn render_footer( fn render_footer(&self, cx: &mut ViewContext<Picker<Self>>) -> Option<AnyElement> {
&self, if self.last_query.is_empty() {
cx: &mut ViewContext<Picker<Self>>, return None;
) -> Option<AnyElement<Picker<Self>>> {
if !self.last_query.is_empty() {
let theme = &theme::current(cx);
let style = theme.picker.footer.clone();
enum BranchCreateButton {}
Some(
Flex::row().with_child(MouseEventHandler::new::<BranchCreateButton, _>(0, cx, |state, _| {
let style = style.style_for(state);
Label::new("Create branch", style.label.clone())
.contained()
.with_style(style.container)
})
.with_cursor_style(CursorStyle::PointingHand)
.on_down(MouseButton::Left, |_, _, cx| {
cx.spawn(|picker, mut cx| async move {
picker.update(&mut cx, |this, cx| {
let project = this.delegate().workspace.read(cx).project().read(cx);
let current_pick = &this.delegate().last_query;
let mut cwd = project
.visible_worktrees(cx)
.next()
.ok_or_else(|| anyhow!("There are no visisible worktrees."))?
.read(cx)
.abs_path()
.to_path_buf();
cwd.push(".git");
let repo = project
.fs()
.open_repo(&cwd)
.ok_or_else(|| anyhow!("Could not open repository at path `{}`", cwd.as_os_str().to_string_lossy()))?;
let repo = repo
.lock();
let status = repo
.create_branch(&current_pick);
if status.is_err() {
this.delegate().display_error_toast(format!("Failed to create branch '{current_pick}', check for conflicts or unstashed files"), cx);
status?;
}
let status = repo.change_branch(&current_pick);
if status.is_err() {
this.delegate().display_error_toast(format!("Failed to chec branch '{current_pick}', check for conflicts or unstashed files"), cx);
status?;
}
cx.emit(PickerEvent::Dismiss);
Ok::<(), anyhow::Error>(())
})
}).detach();
})).aligned().right()
.into_any(),
)
} else {
None
} }
Some(
h_stack().mr_3().pb_2().child(h_stack().w_full()).child(
Button::new("branch-picker-create-branch-button", "Create branch").on_click(
cx.listener(|_, _, cx| {
cx.spawn(|picker, mut cx| async move {
picker.update(&mut cx, |this, cx| {
let project = this.delegate.workspace.read(cx).project().read(cx);
let current_pick = &this.delegate.last_query;
let mut cwd = project
.visible_worktrees(cx)
.next()
.ok_or_else(|| anyhow!("There are no visisible worktrees."))?
.read(cx)
.abs_path()
.to_path_buf();
cwd.push(".git");
let repo = project
.fs()
.open_repo(&cwd)
.ok_or_else(|| anyhow!("Could not open repository at path `{}`", cwd.as_os_str().to_string_lossy()))?;
let repo = repo
.lock();
let status = repo
.create_branch(&current_pick);
if status.is_err() {
this.delegate.display_error_toast(format!("Failed to create branch '{current_pick}', check for conflicts or unstashed files"), cx);
status?;
}
let status = repo.change_branch(&current_pick);
if status.is_err() {
this.delegate.display_error_toast(format!("Failed to chec branch '{current_pick}', check for conflicts or unstashed files"), cx);
status?;
}
this.cancel(&Default::default(), cx);
Ok::<(), anyhow::Error>(())
})
}).detach_and_log_err(cx);
}),
).style(ui::ButtonStyle::Filled)).into_any_element(),
)
} }
} }

View file

@ -1,17 +0,0 @@
[package]
name = "vcs_menu2"
version = "0.1.0"
edition = "2021"
publish = false
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
fuzzy = {package = "fuzzy2", path = "../fuzzy2"}
fs = {package = "fs2", path = "../fs2"}
gpui = {package = "gpui2", path = "../gpui2"}
picker = {package = "picker2", path = "../picker2"}
util = {path = "../util"}
ui = {package = "ui2", path = "../ui2"}
workspace = {package = "workspace2", path = "../workspace2"}
anyhow.workspace = true

View file

@ -1,358 +0,0 @@
use anyhow::{anyhow, bail, Result};
use fs::repository::Branch;
use fuzzy::{StringMatch, StringMatchCandidate};
use gpui::{
actions, rems, AnyElement, AppContext, DismissEvent, Element, EventEmitter, FocusHandle,
FocusableView, InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled,
Subscription, Task, View, ViewContext, VisualContext, WindowContext,
};
use picker::{Picker, PickerDelegate};
use std::{ops::Not, sync::Arc};
use ui::{
h_stack, v_stack, Button, ButtonCommon, Clickable, HighlightedLabel, Label, LabelCommon,
LabelSize, ListItem, ListItemSpacing, Selectable,
};
use util::ResultExt;
use workspace::{ModalView, Toast, Workspace};
actions!(branches, [OpenRecent]);
pub fn init(cx: &mut AppContext) {
// todo!() po
cx.observe_new_views(|workspace: &mut Workspace, _| {
workspace.register_action(|workspace, action, cx| {
BranchList::toggle_modal(workspace, action, cx).log_err();
});
})
.detach();
}
pub struct BranchList {
pub picker: View<Picker<BranchListDelegate>>,
rem_width: f32,
_subscription: Subscription,
}
impl BranchList {
fn new(delegate: BranchListDelegate, rem_width: f32, cx: &mut ViewContext<Self>) -> Self {
let picker = cx.new_view(|cx| Picker::new(delegate, cx));
let _subscription = cx.subscribe(&picker, |_, _, _, cx| cx.emit(DismissEvent));
Self {
picker,
rem_width,
_subscription,
}
}
fn toggle_modal(
workspace: &mut Workspace,
_: &OpenRecent,
cx: &mut ViewContext<Workspace>,
) -> Result<()> {
// Modal branch picker has a longer trailoff than a popover one.
let delegate = BranchListDelegate::new(workspace, cx.view().clone(), 70, cx)?;
workspace.toggle_modal(cx, |cx| BranchList::new(delegate, 34., cx));
Ok(())
}
}
impl ModalView for BranchList {}
impl EventEmitter<DismissEvent> for BranchList {}
impl FocusableView for BranchList {
fn focus_handle(&self, cx: &AppContext) -> FocusHandle {
self.picker.focus_handle(cx)
}
}
impl Render for BranchList {
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
v_stack()
.w(rems(self.rem_width))
.child(self.picker.clone())
.on_mouse_down_out(cx.listener(|this, _, cx| {
this.picker.update(cx, |this, cx| {
this.cancel(&Default::default(), cx);
})
}))
}
}
pub fn build_branch_list(
workspace: View<Workspace>,
cx: &mut WindowContext<'_>,
) -> Result<View<BranchList>> {
let delegate = workspace.update(cx, |workspace, cx| {
BranchListDelegate::new(workspace, cx.view().clone(), 29, cx)
})?;
Ok(cx.new_view(move |cx| BranchList::new(delegate, 20., cx)))
}
pub struct BranchListDelegate {
matches: Vec<StringMatch>,
all_branches: Vec<Branch>,
workspace: View<Workspace>,
selected_index: usize,
last_query: String,
/// Max length of branch name before we truncate it and add a trailing `...`.
branch_name_trailoff_after: usize,
}
impl BranchListDelegate {
fn new(
workspace: &Workspace,
handle: View<Workspace>,
branch_name_trailoff_after: usize,
cx: &AppContext,
) -> Result<Self> {
let project = workspace.project().read(&cx);
let Some(worktree) = project.visible_worktrees(cx).next() else {
bail!("Cannot update branch list as there are no visible worktrees")
};
let mut cwd = worktree.read(cx).abs_path().to_path_buf();
cwd.push(".git");
let Some(repo) = project.fs().open_repo(&cwd) else {
bail!("Project does not have associated git repository.")
};
let all_branches = repo.lock().branches()?;
Ok(Self {
matches: vec![],
workspace: handle,
all_branches,
selected_index: 0,
last_query: Default::default(),
branch_name_trailoff_after,
})
}
fn display_error_toast(&self, message: String, cx: &mut WindowContext<'_>) {
const GIT_CHECKOUT_FAILURE_ID: usize = 2048;
self.workspace.update(cx, |model, ctx| {
model.show_toast(Toast::new(GIT_CHECKOUT_FAILURE_ID, message), ctx)
});
}
}
impl PickerDelegate for BranchListDelegate {
type ListItem = ListItem;
fn placeholder_text(&self) -> Arc<str> {
"Select branch...".into()
}
fn match_count(&self) -> usize {
self.matches.len()
}
fn selected_index(&self) -> usize {
self.selected_index
}
fn set_selected_index(&mut self, ix: usize, _: &mut ViewContext<Picker<Self>>) {
self.selected_index = ix;
}
fn update_matches(&mut self, query: String, cx: &mut ViewContext<Picker<Self>>) -> Task<()> {
cx.spawn(move |picker, mut cx| async move {
let candidates = picker.update(&mut cx, |view, _| {
const RECENT_BRANCHES_COUNT: usize = 10;
let mut branches = view.delegate.all_branches.clone();
if query.is_empty() && branches.len() > RECENT_BRANCHES_COUNT {
// Truncate list of recent branches
// Do a partial sort to show recent-ish branches first.
branches.select_nth_unstable_by(RECENT_BRANCHES_COUNT - 1, |lhs, rhs| {
rhs.unix_timestamp.cmp(&lhs.unix_timestamp)
});
branches.truncate(RECENT_BRANCHES_COUNT);
branches.sort_unstable_by(|lhs, rhs| lhs.name.cmp(&rhs.name));
}
branches
.into_iter()
.enumerate()
.map(|(ix, command)| StringMatchCandidate {
id: ix,
char_bag: command.name.chars().collect(),
string: command.name.into(),
})
.collect::<Vec<StringMatchCandidate>>()
});
let Some(candidates) = candidates.log_err() else {
return;
};
let matches = if query.is_empty() {
candidates
.into_iter()
.enumerate()
.map(|(index, candidate)| StringMatch {
candidate_id: index,
string: candidate.string,
positions: Vec::new(),
score: 0.0,
})
.collect()
} else {
fuzzy::match_strings(
&candidates,
&query,
true,
10000,
&Default::default(),
cx.background_executor().clone(),
)
.await
};
picker
.update(&mut cx, |picker, _| {
let delegate = &mut picker.delegate;
delegate.matches = matches;
if delegate.matches.is_empty() {
delegate.selected_index = 0;
} else {
delegate.selected_index =
core::cmp::min(delegate.selected_index, delegate.matches.len() - 1);
}
delegate.last_query = query;
})
.log_err();
})
}
fn confirm(&mut self, _: bool, cx: &mut ViewContext<Picker<Self>>) {
let current_pick = self.selected_index();
let Some(current_pick) = self
.matches
.get(current_pick)
.map(|pick| pick.string.clone())
else {
return;
};
cx.spawn(|picker, mut cx| async move {
picker
.update(&mut cx, |this, cx| {
let project = this.delegate.workspace.read(cx).project().read(cx);
let mut cwd = project
.visible_worktrees(cx)
.next()
.ok_or_else(|| anyhow!("There are no visisible worktrees."))?
.read(cx)
.abs_path()
.to_path_buf();
cwd.push(".git");
let status = project
.fs()
.open_repo(&cwd)
.ok_or_else(|| {
anyhow!(
"Could not open repository at path `{}`",
cwd.as_os_str().to_string_lossy()
)
})?
.lock()
.change_branch(&current_pick);
if status.is_err() {
this.delegate.display_error_toast(format!("Failed to checkout branch '{current_pick}', check for conflicts or unstashed files"), cx);
status?;
}
cx.emit(DismissEvent);
Ok::<(), anyhow::Error>(())
})
.log_err();
})
.detach();
}
fn dismissed(&mut self, cx: &mut ViewContext<Picker<Self>>) {
cx.emit(DismissEvent);
}
fn render_match(
&self,
ix: usize,
selected: bool,
_cx: &mut ViewContext<Picker<Self>>,
) -> Option<Self::ListItem> {
let hit = &self.matches[ix];
let shortened_branch_name =
util::truncate_and_trailoff(&hit.string, self.branch_name_trailoff_after);
let highlights: Vec<_> = hit
.positions
.iter()
.filter(|index| index < &&self.branch_name_trailoff_after)
.copied()
.collect();
Some(
ListItem::new(SharedString::from(format!("vcs-menu-{ix}")))
.inset(true)
.spacing(ListItemSpacing::Sparse)
.selected(selected)
.start_slot(HighlightedLabel::new(shortened_branch_name, highlights)),
)
}
fn render_header(&self, _: &mut ViewContext<Picker<Self>>) -> Option<AnyElement> {
let label = if self.last_query.is_empty() {
h_stack()
.ml_3()
.child(Label::new("Recent branches").size(LabelSize::Small))
} else {
let match_label = self.matches.is_empty().not().then(|| {
let suffix = if self.matches.len() == 1 { "" } else { "es" };
Label::new(format!("{} match{}", self.matches.len(), suffix)).size(LabelSize::Small)
});
h_stack()
.px_3()
.h_full()
.justify_between()
.child(Label::new("Branches").size(LabelSize::Small))
.children(match_label)
};
Some(label.into_any())
}
fn render_footer(&self, cx: &mut ViewContext<Picker<Self>>) -> Option<AnyElement> {
if self.last_query.is_empty() {
return None;
}
Some(
h_stack().mr_3().pb_2().child(h_stack().w_full()).child(
Button::new("branch-picker-create-branch-button", "Create branch").on_click(
cx.listener(|_, _, cx| {
cx.spawn(|picker, mut cx| async move {
picker.update(&mut cx, |this, cx| {
let project = this.delegate.workspace.read(cx).project().read(cx);
let current_pick = &this.delegate.last_query;
let mut cwd = project
.visible_worktrees(cx)
.next()
.ok_or_else(|| anyhow!("There are no visisible worktrees."))?
.read(cx)
.abs_path()
.to_path_buf();
cwd.push(".git");
let repo = project
.fs()
.open_repo(&cwd)
.ok_or_else(|| anyhow!("Could not open repository at path `{}`", cwd.as_os_str().to_string_lossy()))?;
let repo = repo
.lock();
let status = repo
.create_branch(&current_pick);
if status.is_err() {
this.delegate.display_error_toast(format!("Failed to create branch '{current_pick}', check for conflicts or unstashed files"), cx);
status?;
}
let status = repo.change_branch(&current_pick);
if status.is_err() {
this.delegate.display_error_toast(format!("Failed to chec branch '{current_pick}', check for conflicts or unstashed files"), cx);
status?;
}
this.cancel(&Default::default(), cx);
Ok::<(), anyhow::Error>(())
})
}).detach_and_log_err(cx);
}),
).style(ui::ButtonStyle::Filled)).into_any_element(),
)
}
}

View file

@ -11,21 +11,22 @@ path = "src/welcome.rs"
test-support = [] test-support = []
[dependencies] [dependencies]
client = { path = "../client" } client = { package = "client2", path = "../client2" }
editor = { path = "../editor" } editor = { package = "editor2", path = "../editor2" }
fs = { path = "../fs" } fs = { package = "fs2", path = "../fs2" }
fuzzy = { path = "../fuzzy" } fuzzy = { package = "fuzzy2", path = "../fuzzy2" }
gpui = { path = "../gpui" } gpui = { package = "gpui2", path = "../gpui2" }
db = { path = "../db" } ui = { package = "ui2", path = "../ui2" }
install_cli = { path = "../install_cli" } db = { package = "db2", path = "../db2" }
project = { path = "../project" } install_cli = { package = "install_cli2", path = "../install_cli2" }
settings = { path = "../settings" } project = { package = "project2", path = "../project2" }
theme = { path = "../theme" } settings = { package = "settings2", path = "../settings2" }
theme_selector = { path = "../theme_selector" } theme = { package = "theme2", path = "../theme2" }
theme_selector = { package = "theme_selector2", path = "../theme_selector2" }
util = { path = "../util" } util = { path = "../util" }
picker = { path = "../picker" } picker = { package = "picker2", path = "../picker2" }
workspace = { path = "../workspace" } workspace = { package = "workspace2", path = "../workspace2" }
vim = { path = "../vim" } vim = { package = "vim2", path = "../vim2" }
anyhow.workspace = true anyhow.workspace = true
log.workspace = true log.workspace = true
@ -33,4 +34,4 @@ schemars.workspace = true
serde.workspace = true serde.workspace = true
[dev-dependencies] [dev-dependencies]
editor = { path = "../editor", features = ["test-support"] } editor = { package = "editor2", path = "../editor2", features = ["test-support"] }

View file

@ -1,22 +1,24 @@
use super::base_keymap_setting::BaseKeymap; use super::base_keymap_setting::BaseKeymap;
use fuzzy::{match_strings, StringMatch, StringMatchCandidate}; use fuzzy::{match_strings, StringMatch, StringMatchCandidate};
use gpui::{ use gpui::{
actions, actions, AppContext, DismissEvent, EventEmitter, FocusableView, Render, Task, View,
elements::{Element as _, Label}, ViewContext, VisualContext, WeakView,
AppContext, Task, ViewContext,
}; };
use picker::{Picker, PickerDelegate, PickerEvent}; use picker::{Picker, PickerDelegate};
use project::Fs; use project::Fs;
use settings::update_settings_file; use settings::{update_settings_file, Settings};
use std::sync::Arc; use std::sync::Arc;
use ui::{prelude::*, ListItem, ListItemSpacing};
use util::ResultExt; use util::ResultExt;
use workspace::Workspace; use workspace::{ui::HighlightedLabel, ModalView, Workspace};
actions!(welcome, [ToggleBaseKeymapSelector]); actions!(welcome, [ToggleBaseKeymapSelector]);
pub fn init(cx: &mut AppContext) { pub fn init(cx: &mut AppContext) {
cx.add_action(toggle); cx.observe_new_views(|workspace: &mut Workspace, _cx| {
BaseKeymapSelector::init(cx); workspace.register_action(toggle);
})
.detach();
} }
pub fn toggle( pub fn toggle(
@ -24,28 +26,69 @@ pub fn toggle(
_: &ToggleBaseKeymapSelector, _: &ToggleBaseKeymapSelector,
cx: &mut ViewContext<Workspace>, cx: &mut ViewContext<Workspace>,
) { ) {
workspace.toggle_modal(cx, |workspace, cx| { let fs = workspace.app_state().fs.clone();
let fs = workspace.app_state().fs.clone(); workspace.toggle_modal(cx, |cx| {
cx.add_view(|cx| BaseKeymapSelector::new(BaseKeymapSelectorDelegate::new(fs, cx), cx)) BaseKeymapSelector::new(
BaseKeymapSelectorDelegate::new(cx.view().downgrade(), fs, cx),
cx,
)
}); });
} }
pub type BaseKeymapSelector = Picker<BaseKeymapSelectorDelegate>; pub struct BaseKeymapSelector {
focus_handle: gpui::FocusHandle,
picker: View<Picker<BaseKeymapSelectorDelegate>>,
}
impl FocusableView for BaseKeymapSelector {
fn focus_handle(&self, _cx: &AppContext) -> gpui::FocusHandle {
self.focus_handle.clone()
}
}
impl EventEmitter<DismissEvent> for BaseKeymapSelector {}
impl ModalView for BaseKeymapSelector {}
impl BaseKeymapSelector {
pub fn new(
delegate: BaseKeymapSelectorDelegate,
cx: &mut ViewContext<BaseKeymapSelector>,
) -> Self {
let picker = cx.new_view(|cx| Picker::new(delegate, cx));
let focus_handle = cx.focus_handle();
Self {
focus_handle,
picker,
}
}
}
impl Render for BaseKeymapSelector {
fn render(&mut self, _cx: &mut ViewContext<Self>) -> impl IntoElement {
self.picker.clone()
}
}
pub struct BaseKeymapSelectorDelegate { pub struct BaseKeymapSelectorDelegate {
view: WeakView<BaseKeymapSelector>,
matches: Vec<StringMatch>, matches: Vec<StringMatch>,
selected_index: usize, selected_index: usize,
fs: Arc<dyn Fs>, fs: Arc<dyn Fs>,
} }
impl BaseKeymapSelectorDelegate { impl BaseKeymapSelectorDelegate {
fn new(fs: Arc<dyn Fs>, cx: &mut ViewContext<BaseKeymapSelector>) -> Self { fn new(
let base = settings::get::<BaseKeymap>(cx); weak_view: WeakView<BaseKeymapSelector>,
fs: Arc<dyn Fs>,
cx: &mut ViewContext<BaseKeymapSelector>,
) -> Self {
let base = BaseKeymap::get(None, cx);
let selected_index = BaseKeymap::OPTIONS let selected_index = BaseKeymap::OPTIONS
.iter() .iter()
.position(|(_, value)| value == base) .position(|(_, value)| value == base)
.unwrap_or(0); .unwrap_or(0);
Self { Self {
view: weak_view,
matches: Vec::new(), matches: Vec::new(),
selected_index, selected_index,
fs, fs,
@ -54,6 +97,8 @@ impl BaseKeymapSelectorDelegate {
} }
impl PickerDelegate for BaseKeymapSelectorDelegate { impl PickerDelegate for BaseKeymapSelectorDelegate {
type ListItem = ui::ListItem;
fn placeholder_text(&self) -> Arc<str> { fn placeholder_text(&self) -> Arc<str> {
"Select a base keymap...".into() "Select a base keymap...".into()
} }
@ -66,16 +111,20 @@ impl PickerDelegate for BaseKeymapSelectorDelegate {
self.selected_index self.selected_index
} }
fn set_selected_index(&mut self, ix: usize, _: &mut ViewContext<BaseKeymapSelector>) { fn set_selected_index(
&mut self,
ix: usize,
_: &mut ViewContext<Picker<BaseKeymapSelectorDelegate>>,
) {
self.selected_index = ix; self.selected_index = ix;
} }
fn update_matches( fn update_matches(
&mut self, &mut self,
query: String, query: String,
cx: &mut ViewContext<BaseKeymapSelector>, cx: &mut ViewContext<Picker<BaseKeymapSelectorDelegate>>,
) -> Task<()> { ) -> Task<()> {
let background = cx.background().clone(); let background = cx.background_executor().clone();
let candidates = BaseKeymap::names() let candidates = BaseKeymap::names()
.enumerate() .enumerate()
.map(|(id, name)| StringMatchCandidate { .map(|(id, name)| StringMatchCandidate {
@ -110,43 +159,50 @@ impl PickerDelegate for BaseKeymapSelectorDelegate {
}; };
this.update(&mut cx, |this, _| { this.update(&mut cx, |this, _| {
let delegate = this.delegate_mut(); this.delegate.matches = matches;
delegate.matches = matches; this.delegate.selected_index = this
delegate.selected_index = delegate .delegate
.selected_index .selected_index
.min(delegate.matches.len().saturating_sub(1)); .min(this.delegate.matches.len().saturating_sub(1));
}) })
.log_err(); .log_err();
}) })
} }
fn confirm(&mut self, _: bool, cx: &mut ViewContext<BaseKeymapSelector>) { fn confirm(&mut self, _: bool, cx: &mut ViewContext<Picker<BaseKeymapSelectorDelegate>>) {
if let Some(selection) = self.matches.get(self.selected_index) { if let Some(selection) = self.matches.get(self.selected_index) {
let base_keymap = BaseKeymap::from_names(&selection.string); let base_keymap = BaseKeymap::from_names(&selection.string);
update_settings_file::<BaseKeymap>(self.fs.clone(), cx, move |setting| { update_settings_file::<BaseKeymap>(self.fs.clone(), cx, move |setting| {
*setting = Some(base_keymap) *setting = Some(base_keymap)
}); });
} }
cx.emit(PickerEvent::Dismiss);
self.view
.update(cx, |_, cx| {
cx.emit(DismissEvent);
})
.ok();
} }
fn dismissed(&mut self, _cx: &mut ViewContext<BaseKeymapSelector>) {} fn dismissed(&mut self, _cx: &mut ViewContext<Picker<BaseKeymapSelectorDelegate>>) {}
fn render_match( fn render_match(
&self, &self,
ix: usize, ix: usize,
mouse_state: &mut gpui::MouseState,
selected: bool, selected: bool,
cx: &gpui::AppContext, _cx: &mut gpui::ViewContext<Picker<Self>>,
) -> gpui::AnyElement<Picker<Self>> { ) -> Option<Self::ListItem> {
let theme = &theme::current(cx);
let keymap_match = &self.matches[ix]; let keymap_match = &self.matches[ix];
let style = theme.picker.item.in_state(selected).style_for(mouse_state);
Label::new(keymap_match.string.clone(), style.label.clone()) Some(
.with_highlights(keymap_match.positions.clone()) ListItem::new(ix)
.contained() .inset(true)
.with_style(style.container) .spacing(ListItemSpacing::Sparse)
.into_any() .selected(selected)
.child(HighlightedLabel::new(
keymap_match.string.clone(),
keymap_match.positions.clone(),
)),
)
} }
} }

View file

@ -1,6 +1,6 @@
use schemars::JsonSchema; use schemars::JsonSchema;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use settings::Setting; use settings::Settings;
#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)] #[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
pub enum BaseKeymap { pub enum BaseKeymap {
@ -44,7 +44,7 @@ impl BaseKeymap {
} }
} }
impl Setting for BaseKeymap { impl Settings for BaseKeymap {
const KEY: Option<&'static str> = Some("base_keymap"); const KEY: Option<&'static str> = Some("base_keymap");
type FileContent = Option<Self>; type FileContent = Option<Self>;
@ -52,7 +52,7 @@ impl Setting for BaseKeymap {
fn load( fn load(
default_value: &Self::FileContent, default_value: &Self::FileContent,
user_values: &[&Self::FileContent], user_values: &[&Self::FileContent],
_: &gpui::AppContext, _: &mut gpui::AppContext,
) -> anyhow::Result<Self> ) -> anyhow::Result<Self>
where where
Self: Sized, Self: Sized,

View file

@ -1,19 +1,21 @@
mod base_keymap_picker; mod base_keymap_picker;
mod base_keymap_setting; mod base_keymap_setting;
use crate::base_keymap_picker::ToggleBaseKeymapSelector;
use client::TelemetrySettings; use client::TelemetrySettings;
use db::kvp::KEY_VALUE_STORE; use db::kvp::KEY_VALUE_STORE;
use gpui::{ use gpui::{
elements::{Flex, Label, ParentElement}, svg, AnyElement, AppContext, EventEmitter, FocusHandle, FocusableView, InteractiveElement,
AnyElement, AppContext, Element, Entity, Subscription, View, ViewContext, WeakViewHandle, ParentElement, Render, Styled, Subscription, View, ViewContext, VisualContext, WeakView,
WindowContext,
}; };
use settings::{update_settings_file, SettingsStore}; use settings::{Settings, SettingsStore};
use std::{borrow::Cow, sync::Arc}; use std::sync::Arc;
use ui::{prelude::*, Checkbox};
use vim::VimModeSetting; use vim::VimModeSetting;
use workspace::{ use workspace::{
dock::DockPosition, item::Item, open_new, AppState, PaneBackdrop, Welcome, Workspace, dock::DockPosition,
WorkspaceId, item::{Item, ItemEvent},
open_new, AppState, Welcome, Workspace, WorkspaceId,
}; };
pub use base_keymap_setting::BaseKeymap; pub use base_keymap_setting::BaseKeymap;
@ -21,22 +23,25 @@ pub use base_keymap_setting::BaseKeymap;
pub const FIRST_OPEN: &str = "first_open"; pub const FIRST_OPEN: &str = "first_open";
pub fn init(cx: &mut AppContext) { pub fn init(cx: &mut AppContext) {
settings::register::<BaseKeymap>(cx); BaseKeymap::register(cx);
cx.add_action(|workspace: &mut Workspace, _: &Welcome, cx| { cx.observe_new_views(|workspace: &mut Workspace, _cx| {
let welcome_page = cx.add_view(|cx| WelcomePage::new(workspace, cx)); workspace.register_action(|workspace, _: &Welcome, cx| {
workspace.add_item(Box::new(welcome_page), cx) let welcome_page = cx.new_view(|cx| WelcomePage::new(workspace, cx));
}); workspace.add_item(Box::new(welcome_page), cx)
});
})
.detach();
base_keymap_picker::init(cx); base_keymap_picker::init(cx);
} }
pub fn show_welcome_experience(app_state: &Arc<AppState>, cx: &mut AppContext) { pub fn show_welcome_view(app_state: &Arc<AppState>, cx: &mut AppContext) {
open_new(&app_state, cx, |workspace, cx| { open_new(&app_state, cx, |workspace, cx| {
workspace.toggle_dock(DockPosition::Left, cx); workspace.toggle_dock(DockPosition::Left, cx);
let welcome_page = cx.add_view(|cx| WelcomePage::new(workspace, cx)); let welcome_page = cx.new_view(|cx| WelcomePage::new(workspace, cx));
workspace.add_item_to_center(Box::new(welcome_page.clone()), cx); workspace.add_item_to_center(Box::new(welcome_page.clone()), cx);
cx.focus(&welcome_page); cx.focus_view(&welcome_page);
cx.notify(); cx.notify();
}) })
.detach(); .detach();
@ -47,227 +52,213 @@ pub fn show_welcome_experience(app_state: &Arc<AppState>, cx: &mut AppContext) {
} }
pub struct WelcomePage { pub struct WelcomePage {
workspace: WeakViewHandle<Workspace>, workspace: WeakView<Workspace>,
focus_handle: FocusHandle,
_settings_subscription: Subscription, _settings_subscription: Subscription,
} }
impl Entity for WelcomePage { impl Render for WelcomePage {
type Event = (); fn render(&mut self, cx: &mut gpui::ViewContext<Self>) -> impl IntoElement {
} h_stack().full().track_focus(&self.focus_handle).child(
v_stack()
impl View for WelcomePage { .w_96()
fn ui_name() -> &'static str { .gap_4()
"WelcomePage" .mx_auto()
} .child(
svg()
fn render(&mut self, cx: &mut gpui::ViewContext<Self>) -> AnyElement<Self> { .path("icons/logo_96.svg")
let self_handle = cx.handle(); .text_color(gpui::white())
let theme = theme::current(cx); .w(px(96.))
let width = theme.welcome.page_width; .h(px(96.))
.mx_auto(),
let telemetry_settings = *settings::get::<TelemetrySettings>(cx);
let vim_mode_setting = settings::get::<VimModeSetting>(cx).0;
enum Metrics {}
enum Diagnostics {}
PaneBackdrop::new(
self_handle.id(),
Flex::column()
.with_child(
Flex::column()
.with_child(
theme::ui::svg(&theme.welcome.logo)
.aligned()
.contained()
.aligned(),
)
.with_child(
Label::new(
"Code at the speed of thought",
theme.welcome.logo_subheading.text.clone(),
)
.aligned()
.contained()
.with_style(theme.welcome.logo_subheading.container),
)
.contained()
.with_style(theme.welcome.heading_group)
.constrained()
.with_width(width),
) )
.with_child( .child(
Flex::column() h_stack()
.with_child(theme::ui::cta_button::<theme_selector::Toggle, _, _, _>( .justify_center()
"Choose a theme", .child(Label::new("Code at the speed of thought")),
width,
&theme.welcome.button,
cx,
|_, this, cx| {
if let Some(workspace) = this.workspace.upgrade(cx) {
workspace.update(cx, |workspace, cx| {
theme_selector::toggle(workspace, &Default::default(), cx)
})
}
},
))
.with_child(theme::ui::cta_button::<ToggleBaseKeymapSelector, _, _, _>(
"Choose a keymap",
width,
&theme.welcome.button,
cx,
|_, this, cx| {
if let Some(workspace) = this.workspace.upgrade(cx) {
workspace.update(cx, |workspace, cx| {
base_keymap_picker::toggle(
workspace,
&Default::default(),
cx,
)
})
}
},
))
.with_child(theme::ui::cta_button::<install_cli::Install, _, _, _>(
"Install the CLI",
width,
&theme.welcome.button,
cx,
|_, _, cx| {
cx.app_context()
.spawn(|cx| async move { install_cli::install_cli(&cx).await })
.detach_and_log_err(cx);
},
))
.contained()
.with_style(theme.welcome.button_group)
.constrained()
.with_width(width),
) )
.with_child( .child(
Flex::column() v_stack()
.with_child( .gap_2()
theme::ui::checkbox::<Diagnostics, Self, _>( .child(
"Enable vim mode", Button::new("choose-theme", "Choose a theme")
&theme.welcome.checkbox, .full_width()
vim_mode_setting, .on_click(cx.listener(|this, _, cx| {
0, this.workspace
cx, .update(cx, |workspace, cx| {
|this, checked, cx| { theme_selector::toggle(
if let Some(workspace) = this.workspace.upgrade(cx) { workspace,
let fs = workspace.read(cx).app_state().fs.clone(); &Default::default(),
update_settings_file::<VimModeSetting>( cx,
fs, )
cx, })
move |setting| *setting = Some(checked), .ok();
) })),
}
},
)
.contained()
.with_style(theme.welcome.checkbox_container),
) )
.with_child( .child(
theme::ui::checkbox_with_label::<Metrics, _, Self, _>( Button::new("choose-keymap", "Choose a keymap")
Flex::column() .full_width()
.with_child( .on_click(cx.listener(|this, _, cx| {
Label::new( this.workspace
"Send anonymous usage data", .update(cx, |workspace, cx| {
theme.welcome.checkbox.label.text.clone(), base_keymap_picker::toggle(
workspace,
&Default::default(),
cx,
)
})
.ok();
})),
)
.child(
Button::new("install-cli", "Install the CLI")
.full_width()
.on_click(cx.listener(|_, _, cx| {
cx.app_mut()
.spawn(
|cx| async move { install_cli::install_cli(&cx).await },
) )
.contained() .detach_and_log_err(cx);
.with_style(theme.welcome.checkbox.label.container), })),
),
)
.child(
v_stack()
.p_3()
.gap_2()
.bg(cx.theme().colors().elevated_surface_background)
.border_1()
.border_color(cx.theme().colors().border)
.rounded_md()
.child(
h_stack()
.gap_2()
.child(
Checkbox::new(
"enable-vim",
if VimModeSetting::get_global(cx).0 {
ui::Selection::Selected
} else {
ui::Selection::Unselected
},
) )
.with_child( .on_click(cx.listener(
Label::new( move |this, selection, cx| {
"Help > View Telemetry", this.update_settings::<VimModeSetting>(
theme.welcome.usage_note.text.clone(), selection,
) cx,
.contained() |setting, value| *setting = Some(value),
.with_style(theme.welcome.usage_note.container), );
), },
&theme.welcome.checkbox, )),
telemetry_settings.metrics, )
0, .child(Label::new("Enable vim mode")),
cx,
|this, checked, cx| {
if let Some(workspace) = this.workspace.upgrade(cx) {
let fs = workspace.read(cx).app_state().fs.clone();
update_settings_file::<TelemetrySettings>(
fs,
cx,
move |setting| setting.metrics = Some(checked),
)
}
},
)
.contained()
.with_style(theme.welcome.checkbox_container),
) )
.with_child( .child(
theme::ui::checkbox::<Diagnostics, Self, _>( h_stack()
"Send crash reports", .gap_2()
&theme.welcome.checkbox, .child(
telemetry_settings.diagnostics, Checkbox::new(
1, "enable-telemetry",
cx, if TelemetrySettings::get_global(cx).metrics {
|this, checked, cx| { ui::Selection::Selected
if let Some(workspace) = this.workspace.upgrade(cx) { } else {
let fs = workspace.read(cx).app_state().fs.clone(); ui::Selection::Unselected
update_settings_file::<TelemetrySettings>( },
fs, )
cx, .on_click(cx.listener(
move |setting| setting.diagnostics = Some(checked), move |this, selection, cx| {
) this.update_settings::<TelemetrySettings>(
} selection,
}, cx,
) |settings, value| settings.metrics = Some(value),
.contained() );
.with_style(theme.welcome.checkbox_container), },
)),
)
.child(Label::new("Send anonymous usage data")),
) )
.contained() .child(
.with_style(theme.welcome.checkbox_group) h_stack()
.constrained() .gap_2()
.with_width(width), .child(
) Checkbox::new(
.constrained() "enable-crash",
.with_max_width(width) if TelemetrySettings::get_global(cx).diagnostics {
.contained() ui::Selection::Selected
.with_uniform_padding(10.) } else {
.aligned() ui::Selection::Unselected
.into_any(), },
)
.on_click(cx.listener(
move |this, selection, cx| {
this.update_settings::<TelemetrySettings>(
selection,
cx,
|settings, value| {
settings.diagnostics = Some(value)
},
);
},
)),
)
.child(Label::new("Send crash reports")),
),
),
) )
.into_any_named("welcome page")
} }
} }
impl WelcomePage { impl WelcomePage {
pub fn new(workspace: &Workspace, cx: &mut ViewContext<Self>) -> Self { pub fn new(workspace: &Workspace, cx: &mut ViewContext<Self>) -> Self {
WelcomePage { WelcomePage {
focus_handle: cx.focus_handle(),
workspace: workspace.weak_handle(), workspace: workspace.weak_handle(),
_settings_subscription: cx.observe_global::<SettingsStore, _>(move |_, cx| cx.notify()), _settings_subscription: cx.observe_global::<SettingsStore>(move |_, cx| cx.notify()),
}
}
fn update_settings<T: Settings>(
&mut self,
selection: &Selection,
cx: &mut ViewContext<Self>,
callback: impl 'static + Send + Fn(&mut T::FileContent, bool),
) {
if let Some(workspace) = self.workspace.upgrade() {
let fs = workspace.read(cx).app_state().fs.clone();
let selection = *selection;
settings::update_settings_file::<T>(fs, cx, move |settings| {
let value = match selection {
Selection::Unselected => false,
Selection::Selected => true,
_ => return,
};
callback(settings, value)
});
} }
} }
} }
impl Item for WelcomePage { impl EventEmitter<ItemEvent> for WelcomePage {}
fn tab_tooltip_text(&self, _: &AppContext) -> Option<Cow<str>> {
Some("Welcome to Zed!".into())
}
fn tab_content<T: 'static>( impl FocusableView for WelcomePage {
&self, fn focus_handle(&self, _: &AppContext) -> gpui::FocusHandle {
_detail: Option<usize>, self.focus_handle.clone()
style: &theme::Tab, }
_cx: &gpui::AppContext, }
) -> AnyElement<T> {
Flex::row() impl Item for WelcomePage {
.with_child( type Event = ItemEvent;
Label::new("Welcome to Zed!", style.label.clone())
.aligned() fn tab_content(&self, _: Option<usize>, selected: bool, _: &WindowContext) -> AnyElement {
.contained(), Label::new("Welcome to Zed!")
) .color(if selected {
.into_any() Color::Default
} else {
Color::Muted
})
.into_any_element()
} }
fn show_toolbar(&self) -> bool { fn show_toolbar(&self) -> bool {
@ -278,10 +269,15 @@ impl Item for WelcomePage {
&self, &self,
_workspace_id: WorkspaceId, _workspace_id: WorkspaceId,
cx: &mut ViewContext<Self>, cx: &mut ViewContext<Self>,
) -> Option<Self> { ) -> Option<View<Self>> {
Some(WelcomePage { Some(cx.new_view(|cx| WelcomePage {
focus_handle: cx.focus_handle(),
workspace: self.workspace.clone(), workspace: self.workspace.clone(),
_settings_subscription: cx.observe_global::<SettingsStore, _>(move |_, cx| cx.notify()), _settings_subscription: cx.observe_global::<SettingsStore>(move |_, cx| cx.notify()),
}) }))
}
fn to_item_events(event: &Self::Event, mut f: impl FnMut(workspace::item::ItemEvent)) {
f(*event)
} }
} }

View file

@ -1,37 +0,0 @@
[package]
name = "welcome2"
version = "0.1.0"
edition = "2021"
publish = false
[lib]
path = "src/welcome.rs"
[features]
test-support = []
[dependencies]
client = { package = "client2", path = "../client2" }
editor = { package = "editor2", path = "../editor2" }
fs = { package = "fs2", path = "../fs2" }
fuzzy = { package = "fuzzy2", path = "../fuzzy2" }
gpui = { package = "gpui2", path = "../gpui2" }
ui = { package = "ui2", path = "../ui2" }
db = { package = "db2", path = "../db2" }
install_cli = { package = "install_cli2", path = "../install_cli2" }
project = { package = "project2", path = "../project2" }
settings = { package = "settings2", path = "../settings2" }
theme = { package = "theme2", path = "../theme2" }
theme_selector = { package = "theme_selector2", path = "../theme_selector2" }
util = { path = "../util" }
picker = { package = "picker2", path = "../picker2" }
workspace = { package = "workspace2", path = "../workspace2" }
vim = { package = "vim2", path = "../vim2" }
anyhow.workspace = true
log.workspace = true
schemars.workspace = true
serde.workspace = true
[dev-dependencies]
editor = { package = "editor2", path = "../editor2", features = ["test-support"] }

View file

@ -1,208 +0,0 @@
use super::base_keymap_setting::BaseKeymap;
use fuzzy::{match_strings, StringMatch, StringMatchCandidate};
use gpui::{
actions, AppContext, DismissEvent, EventEmitter, FocusableView, Render, Task, View,
ViewContext, VisualContext, WeakView,
};
use picker::{Picker, PickerDelegate};
use project::Fs;
use settings::{update_settings_file, Settings};
use std::sync::Arc;
use ui::{prelude::*, ListItem, ListItemSpacing};
use util::ResultExt;
use workspace::{ui::HighlightedLabel, ModalView, Workspace};
actions!(welcome, [ToggleBaseKeymapSelector]);
pub fn init(cx: &mut AppContext) {
cx.observe_new_views(|workspace: &mut Workspace, _cx| {
workspace.register_action(toggle);
})
.detach();
}
pub fn toggle(
workspace: &mut Workspace,
_: &ToggleBaseKeymapSelector,
cx: &mut ViewContext<Workspace>,
) {
let fs = workspace.app_state().fs.clone();
workspace.toggle_modal(cx, |cx| {
BaseKeymapSelector::new(
BaseKeymapSelectorDelegate::new(cx.view().downgrade(), fs, cx),
cx,
)
});
}
pub struct BaseKeymapSelector {
focus_handle: gpui::FocusHandle,
picker: View<Picker<BaseKeymapSelectorDelegate>>,
}
impl FocusableView for BaseKeymapSelector {
fn focus_handle(&self, _cx: &AppContext) -> gpui::FocusHandle {
self.focus_handle.clone()
}
}
impl EventEmitter<DismissEvent> for BaseKeymapSelector {}
impl ModalView for BaseKeymapSelector {}
impl BaseKeymapSelector {
pub fn new(
delegate: BaseKeymapSelectorDelegate,
cx: &mut ViewContext<BaseKeymapSelector>,
) -> Self {
let picker = cx.new_view(|cx| Picker::new(delegate, cx));
let focus_handle = cx.focus_handle();
Self {
focus_handle,
picker,
}
}
}
impl Render for BaseKeymapSelector {
fn render(&mut self, _cx: &mut ViewContext<Self>) -> impl IntoElement {
self.picker.clone()
}
}
pub struct BaseKeymapSelectorDelegate {
view: WeakView<BaseKeymapSelector>,
matches: Vec<StringMatch>,
selected_index: usize,
fs: Arc<dyn Fs>,
}
impl BaseKeymapSelectorDelegate {
fn new(
weak_view: WeakView<BaseKeymapSelector>,
fs: Arc<dyn Fs>,
cx: &mut ViewContext<BaseKeymapSelector>,
) -> Self {
let base = BaseKeymap::get(None, cx);
let selected_index = BaseKeymap::OPTIONS
.iter()
.position(|(_, value)| value == base)
.unwrap_or(0);
Self {
view: weak_view,
matches: Vec::new(),
selected_index,
fs,
}
}
}
impl PickerDelegate for BaseKeymapSelectorDelegate {
type ListItem = ui::ListItem;
fn placeholder_text(&self) -> Arc<str> {
"Select a base keymap...".into()
}
fn match_count(&self) -> usize {
self.matches.len()
}
fn selected_index(&self) -> usize {
self.selected_index
}
fn set_selected_index(
&mut self,
ix: usize,
_: &mut ViewContext<Picker<BaseKeymapSelectorDelegate>>,
) {
self.selected_index = ix;
}
fn update_matches(
&mut self,
query: String,
cx: &mut ViewContext<Picker<BaseKeymapSelectorDelegate>>,
) -> Task<()> {
let background = cx.background_executor().clone();
let candidates = BaseKeymap::names()
.enumerate()
.map(|(id, name)| StringMatchCandidate {
id,
char_bag: name.into(),
string: name.into(),
})
.collect::<Vec<_>>();
cx.spawn(|this, mut cx| async move {
let matches = if query.is_empty() {
candidates
.into_iter()
.enumerate()
.map(|(index, candidate)| StringMatch {
candidate_id: index,
string: candidate.string,
positions: Vec::new(),
score: 0.0,
})
.collect()
} else {
match_strings(
&candidates,
&query,
false,
100,
&Default::default(),
background,
)
.await
};
this.update(&mut cx, |this, _| {
this.delegate.matches = matches;
this.delegate.selected_index = this
.delegate
.selected_index
.min(this.delegate.matches.len().saturating_sub(1));
})
.log_err();
})
}
fn confirm(&mut self, _: bool, cx: &mut ViewContext<Picker<BaseKeymapSelectorDelegate>>) {
if let Some(selection) = self.matches.get(self.selected_index) {
let base_keymap = BaseKeymap::from_names(&selection.string);
update_settings_file::<BaseKeymap>(self.fs.clone(), cx, move |setting| {
*setting = Some(base_keymap)
});
}
self.view
.update(cx, |_, cx| {
cx.emit(DismissEvent);
})
.ok();
}
fn dismissed(&mut self, _cx: &mut ViewContext<Picker<BaseKeymapSelectorDelegate>>) {}
fn render_match(
&self,
ix: usize,
selected: bool,
_cx: &mut gpui::ViewContext<Picker<Self>>,
) -> Option<Self::ListItem> {
let keymap_match = &self.matches[ix];
Some(
ListItem::new(ix)
.inset(true)
.spacing(ListItemSpacing::Sparse)
.selected(selected)
.child(HighlightedLabel::new(
keymap_match.string.clone(),
keymap_match.positions.clone(),
)),
)
}
}

View file

@ -1,65 +0,0 @@
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use settings::Settings;
#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
pub enum BaseKeymap {
#[default]
VSCode,
JetBrains,
SublimeText,
Atom,
TextMate,
}
impl BaseKeymap {
pub const OPTIONS: [(&'static str, Self); 5] = [
("VSCode (Default)", Self::VSCode),
("Atom", Self::Atom),
("JetBrains", Self::JetBrains),
("Sublime Text", Self::SublimeText),
("TextMate", Self::TextMate),
];
pub fn asset_path(&self) -> Option<&'static str> {
match self {
BaseKeymap::JetBrains => Some("keymaps/jetbrains.json"),
BaseKeymap::SublimeText => Some("keymaps/sublime_text.json"),
BaseKeymap::Atom => Some("keymaps/atom.json"),
BaseKeymap::TextMate => Some("keymaps/textmate.json"),
BaseKeymap::VSCode => None,
}
}
pub fn names() -> impl Iterator<Item = &'static str> {
Self::OPTIONS.iter().map(|(name, _)| *name)
}
pub fn from_names(option: &str) -> BaseKeymap {
Self::OPTIONS
.iter()
.copied()
.find_map(|(name, value)| (name == option).then(|| value))
.unwrap_or_default()
}
}
impl Settings for BaseKeymap {
const KEY: Option<&'static str> = Some("base_keymap");
type FileContent = Option<Self>;
fn load(
default_value: &Self::FileContent,
user_values: &[&Self::FileContent],
_: &mut gpui::AppContext,
) -> anyhow::Result<Self>
where
Self: Sized,
{
Ok(user_values
.first()
.and_then(|v| **v)
.unwrap_or(default_value.unwrap()))
}
}

View file

@ -1,283 +0,0 @@
mod base_keymap_picker;
mod base_keymap_setting;
use client::TelemetrySettings;
use db::kvp::KEY_VALUE_STORE;
use gpui::{
svg, AnyElement, AppContext, EventEmitter, FocusHandle, FocusableView, InteractiveElement,
ParentElement, Render, Styled, Subscription, View, ViewContext, VisualContext, WeakView,
WindowContext,
};
use settings::{Settings, SettingsStore};
use std::sync::Arc;
use ui::{prelude::*, Checkbox};
use vim::VimModeSetting;
use workspace::{
dock::DockPosition,
item::{Item, ItemEvent},
open_new, AppState, Welcome, Workspace, WorkspaceId,
};
pub use base_keymap_setting::BaseKeymap;
pub const FIRST_OPEN: &str = "first_open";
pub fn init(cx: &mut AppContext) {
BaseKeymap::register(cx);
cx.observe_new_views(|workspace: &mut Workspace, _cx| {
workspace.register_action(|workspace, _: &Welcome, cx| {
let welcome_page = cx.new_view(|cx| WelcomePage::new(workspace, cx));
workspace.add_item(Box::new(welcome_page), cx)
});
})
.detach();
base_keymap_picker::init(cx);
}
pub fn show_welcome_view(app_state: &Arc<AppState>, cx: &mut AppContext) {
open_new(&app_state, cx, |workspace, cx| {
workspace.toggle_dock(DockPosition::Left, cx);
let welcome_page = cx.new_view(|cx| WelcomePage::new(workspace, cx));
workspace.add_item_to_center(Box::new(welcome_page.clone()), cx);
cx.focus_view(&welcome_page);
cx.notify();
})
.detach();
db::write_and_log(cx, || {
KEY_VALUE_STORE.write_kvp(FIRST_OPEN.to_string(), "false".to_string())
});
}
pub struct WelcomePage {
workspace: WeakView<Workspace>,
focus_handle: FocusHandle,
_settings_subscription: Subscription,
}
impl Render for WelcomePage {
fn render(&mut self, cx: &mut gpui::ViewContext<Self>) -> impl IntoElement {
h_stack().full().track_focus(&self.focus_handle).child(
v_stack()
.w_96()
.gap_4()
.mx_auto()
.child(
svg()
.path("icons/logo_96.svg")
.text_color(gpui::white())
.w(px(96.))
.h(px(96.))
.mx_auto(),
)
.child(
h_stack()
.justify_center()
.child(Label::new("Code at the speed of thought")),
)
.child(
v_stack()
.gap_2()
.child(
Button::new("choose-theme", "Choose a theme")
.full_width()
.on_click(cx.listener(|this, _, cx| {
this.workspace
.update(cx, |workspace, cx| {
theme_selector::toggle(
workspace,
&Default::default(),
cx,
)
})
.ok();
})),
)
.child(
Button::new("choose-keymap", "Choose a keymap")
.full_width()
.on_click(cx.listener(|this, _, cx| {
this.workspace
.update(cx, |workspace, cx| {
base_keymap_picker::toggle(
workspace,
&Default::default(),
cx,
)
})
.ok();
})),
)
.child(
Button::new("install-cli", "Install the CLI")
.full_width()
.on_click(cx.listener(|_, _, cx| {
cx.app_mut()
.spawn(
|cx| async move { install_cli::install_cli(&cx).await },
)
.detach_and_log_err(cx);
})),
),
)
.child(
v_stack()
.p_3()
.gap_2()
.bg(cx.theme().colors().elevated_surface_background)
.border_1()
.border_color(cx.theme().colors().border)
.rounded_md()
.child(
h_stack()
.gap_2()
.child(
Checkbox::new(
"enable-vim",
if VimModeSetting::get_global(cx).0 {
ui::Selection::Selected
} else {
ui::Selection::Unselected
},
)
.on_click(cx.listener(
move |this, selection, cx| {
this.update_settings::<VimModeSetting>(
selection,
cx,
|setting, value| *setting = Some(value),
);
},
)),
)
.child(Label::new("Enable vim mode")),
)
.child(
h_stack()
.gap_2()
.child(
Checkbox::new(
"enable-telemetry",
if TelemetrySettings::get_global(cx).metrics {
ui::Selection::Selected
} else {
ui::Selection::Unselected
},
)
.on_click(cx.listener(
move |this, selection, cx| {
this.update_settings::<TelemetrySettings>(
selection,
cx,
|settings, value| settings.metrics = Some(value),
);
},
)),
)
.child(Label::new("Send anonymous usage data")),
)
.child(
h_stack()
.gap_2()
.child(
Checkbox::new(
"enable-crash",
if TelemetrySettings::get_global(cx).diagnostics {
ui::Selection::Selected
} else {
ui::Selection::Unselected
},
)
.on_click(cx.listener(
move |this, selection, cx| {
this.update_settings::<TelemetrySettings>(
selection,
cx,
|settings, value| {
settings.diagnostics = Some(value)
},
);
},
)),
)
.child(Label::new("Send crash reports")),
),
),
)
}
}
impl WelcomePage {
pub fn new(workspace: &Workspace, cx: &mut ViewContext<Self>) -> Self {
WelcomePage {
focus_handle: cx.focus_handle(),
workspace: workspace.weak_handle(),
_settings_subscription: cx.observe_global::<SettingsStore>(move |_, cx| cx.notify()),
}
}
fn update_settings<T: Settings>(
&mut self,
selection: &Selection,
cx: &mut ViewContext<Self>,
callback: impl 'static + Send + Fn(&mut T::FileContent, bool),
) {
if let Some(workspace) = self.workspace.upgrade() {
let fs = workspace.read(cx).app_state().fs.clone();
let selection = *selection;
settings::update_settings_file::<T>(fs, cx, move |settings| {
let value = match selection {
Selection::Unselected => false,
Selection::Selected => true,
_ => return,
};
callback(settings, value)
});
}
}
}
impl EventEmitter<ItemEvent> for WelcomePage {}
impl FocusableView for WelcomePage {
fn focus_handle(&self, _: &AppContext) -> gpui::FocusHandle {
self.focus_handle.clone()
}
}
impl Item for WelcomePage {
type Event = ItemEvent;
fn tab_content(&self, _: Option<usize>, selected: bool, _: &WindowContext) -> AnyElement {
Label::new("Welcome to Zed!")
.color(if selected {
Color::Default
} else {
Color::Muted
})
.into_any_element()
}
fn show_toolbar(&self) -> bool {
false
}
fn clone_on_split(
&self,
_workspace_id: WorkspaceId,
cx: &mut ViewContext<Self>,
) -> Option<View<Self>> {
Some(cx.new_view(|cx| WelcomePage {
focus_handle: cx.focus_handle(),
workspace: self.workspace.clone(),
_settings_subscription: cx.observe_global::<SettingsStore>(move |_, cx| cx.notify()),
}))
}
fn to_item_events(event: &Self::Event, mut f: impl FnMut(workspace::item::ItemEvent)) {
f(*event)
}
}

View file

@ -23,7 +23,7 @@ breadcrumbs = { package = "breadcrumbs2", path = "../breadcrumbs2" }
call = { package = "call2", path = "../call2" } call = { package = "call2", path = "../call2" }
channel = { package = "channel2", path = "../channel2" } channel = { package = "channel2", path = "../channel2" }
cli = { path = "../cli" } cli = { path = "../cli" }
collab_ui = { package = "collab_ui2", path = "../collab_ui2" } collab_ui = { path = "../collab_ui" }
collections = { path = "../collections" } collections = { path = "../collections" }
command_palette = { package="command_palette2", path = "../command_palette2" } command_palette = { package="command_palette2", path = "../command_palette2" }
# component_test = { path = "../component_test" } # component_test = { path = "../component_test" }
@ -56,7 +56,7 @@ outline = { package = "outline2", path = "../outline2" }
project = { package = "project2", path = "../project2" } project = { package = "project2", path = "../project2" }
project_panel = { package = "project_panel2", path = "../project_panel2" } project_panel = { package = "project_panel2", path = "../project_panel2" }
project_symbols = { package = "project_symbols2", path = "../project_symbols2" } project_symbols = { package = "project_symbols2", path = "../project_symbols2" }
quick_action_bar = { package = "quick_action_bar2", path = "../quick_action_bar2" } quick_action_bar = { path = "../quick_action_bar" }
recent_projects = { package = "recent_projects2", path = "../recent_projects2" } recent_projects = { package = "recent_projects2", path = "../recent_projects2" }
rope = { package = "rope2", path = "../rope2"} rope = { package = "rope2", path = "../rope2"}
rpc = { package = "rpc2", path = "../rpc2" } rpc = { package = "rpc2", path = "../rpc2" }
@ -72,7 +72,7 @@ util = { path = "../util" }
semantic_index = { package = "semantic_index2", path = "../semantic_index2" } semantic_index = { package = "semantic_index2", path = "../semantic_index2" }
vim = { package = "vim2", path = "../vim2" } vim = { package = "vim2", path = "../vim2" }
workspace = { package = "workspace2", path = "../workspace2" } workspace = { package = "workspace2", path = "../workspace2" }
welcome = { package = "welcome2", path = "../welcome2" } welcome = { path = "../welcome" }
zed_actions = {package = "zed_actions2", path = "../zed_actions2"} zed_actions = {package = "zed_actions2", path = "../zed_actions2"}
anyhow.workspace = true anyhow.workspace = true
async-compression.workspace = true async-compression.workspace = true